diff --git a/decoupled_router.services.yml b/decoupled_router.services.yml index 90e959c..5753807 100644 --- a/decoupled_router.services.yml +++ b/decoupled_router.services.yml @@ -4,7 +4,8 @@ services: arguments: ['decoupled_router'] decoupled_router.router_path_translator.subscriber: class: Drupal\decoupled_router\EventSubscriber\RouterPathTranslatorSubscriber - arguments: ['@service_container', '@logger.channel.decoupled_router', '@router.no_access_checks', '@module_handler', '@config.factory', '@path_alias.manager'] + arguments: ['@service_container', '@logger.channel.decoupled_router', '@router.no_access_checks', '@module_handler', '@config.factory', '@path_alias.manager', + '@language_manager', '@entity.repository'] tags: - { name: event_subscriber } decoupled_router.redirect_path_translator.subscriber: diff --git a/src/EventSubscriber/RedirectPathTranslatorSubscriber.php b/src/EventSubscriber/RedirectPathTranslatorSubscriber.php index 8d5e8f9..9a44e1e 100644 --- a/src/EventSubscriber/RedirectPathTranslatorSubscriber.php +++ b/src/EventSubscriber/RedirectPathTranslatorSubscriber.php @@ -5,8 +5,10 @@ namespace Drupal\decoupled_router\EventSubscriber; use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\CacheableJsonResponse; use Drupal\Core\GeneratedUrl; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\decoupled_router\PathTranslatorEvent; +use Symfony\Component\HttpFoundation\Request; /** * Event subscriber that processes a path translation with the redirect info. @@ -46,13 +48,40 @@ class RedirectPathTranslatorSubscriber extends RouterPathTranslatorSubscriber { $redirects_trace = []; while (TRUE) { $destination = $this->cleanSubdirInPath($destination, $event->getRequest()); + $path_without_prefix = $destination; + $langcodes = []; + if ($this->languageManager->isMultilingual()) { + $langcodes = [LanguageInterface::LANGCODE_NOT_SPECIFIED]; + $language_negotiation_url = $this->languageManager->getNegotiator() + ->getNegotiationMethodInstance('language-url'); + $router_request = Request::create($destination); + $langcode = $language_negotiation_url->getLangcode($router_request); + + // If language negotiation fails, we add the current language to the + // list of languages to check for redirects, we assume that the + // current language is the language which the user wants to see. + if(!$langcode) { + $langcode = $this->languageManager->getCurrentLanguage()->getId(); + $langcodes[] = $langcode; + } + + $language_prefixes = $this->configFactory->get('language.negotiation')->get('url.prefixes'); + $lang_prefix = $language_prefixes[$langcode] ?? ''; + if ($langcode && ($destination === "/$lang_prefix" || strpos($destination, "/$lang_prefix/") === 0)) { + $langcodes[] = $langcode; + $path_without_prefix = $language_negotiation_url->processInbound($destination, $router_request); + } + } // Find if there is a redirect for this path. - $results = $redirect_storage + $query = $redirect_storage ->getQuery() ->accessCheck(TRUE) // Redirects are stored without the leading slash :-(. - ->condition('redirect_source.path', ltrim($destination, '/')) - ->execute(); + ->condition('redirect_source__path', ltrim($path_without_prefix, '/')); + if (!empty($langcodes)) { + $query->condition('language', $langcodes, 'IN'); + } + $results = $query->execute(); $rid = reset($results); if (!$rid) { break; diff --git a/src/EventSubscriber/RouterPathTranslatorSubscriber.php b/src/EventSubscriber/RouterPathTranslatorSubscriber.php index cc20fad..7ab84ea 100644 --- a/src/EventSubscriber/RouterPathTranslatorSubscriber.php +++ b/src/EventSubscriber/RouterPathTranslatorSubscriber.php @@ -9,7 +9,10 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\ContentEntityType; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityMalformedException; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\TranslatableInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Routing\RouteObjectInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -73,6 +76,27 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { */ protected $aliasManager; + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * The entity repository service. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + + /** + * The langcode if added as a prefix to the path. + * + * @var string + */ + protected $langcode; + /** * RouterPathTranslatorSubscriber constructor. * @@ -88,6 +112,10 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { * The config factory. * @param \Drupal\path_alias\AliasManagerInterface $aliasManager * The alias manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The language manager. + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository. */ public function __construct( ContainerInterface $container, @@ -96,6 +124,8 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { ModuleHandlerInterface $module_handler, ConfigFactoryInterface $config_factory, AliasManagerInterface $aliasManager, + LanguageManagerInterface $language_manager, + EntityRepositoryInterface $entity_repository ) { $this->container = $container; $this->logger = $logger; @@ -103,6 +133,8 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { $this->moduleHandler = $module_handler; $this->configFactory = $config_factory; $this->aliasManager = $aliasManager; + $this->languageManager = $language_manager; + $this->entityRepository = $entity_repository; } /** @@ -116,6 +148,10 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { } $path = $event->getPath(); $path = $this->cleanSubdirInPath($path, $event->getRequest()); + if ($this->languageManager->isMultilingual()) { + $path = $this->getPathFromAlias($path); + } + try { $match_info = $this->router->match($path); } @@ -145,6 +181,14 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { $this->logger->notice('A route has been found but it has no entity information.'); return; } + elseif (!empty($this->langcode)) { + if ($entity->hasTranslation($this->langcode) && method_exists($entity, 'getTranslation')) { + $entity = $entity->getTranslation($this->langcode); + } + else { + $entity = $this->entityRepository->getTranslationFromContext($entity, $this->langcode); + } + } $response->addCacheableDependency($entity); if ($entity->getEntityType() instanceof ContentEntityType) { $can_view = $entity->access('view', NULL, TRUE); @@ -181,9 +225,14 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { return; } $entity_param = $param_uses_uuid ? $entity->id() : $entity->uuid(); - $resolved_url = Url::fromRoute($match_info[RouteObjectInterface::ROUTE_NAME], [ - $route_parameter_entity_key => $entity_param, - ], ['absolute' => TRUE])->toString(TRUE); + $resolved_url = Url::fromRoute( + $match_info[RouteObjectInterface::ROUTE_NAME], + [$route_parameter_entity_key => $entity_param], + [ + 'absolute' => TRUE, + 'language' => $entity->language() + ] + )->toString(TRUE); $response->addCacheableDependency($canonical_url); $response->addCacheableDependency($resolved_url); $is_home_path = $this->resolvedPathIsHomePath($resolved_url->getGeneratedUrl()); @@ -193,6 +242,10 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { $label_accessible = $entity->access('view label', NULL, TRUE); $response->addCacheableDependency($label_accessible); + $langcode = NULL; + if ($entity instanceof TranslatableInterface) { + $langcode = $entity->language()->getId(); + } $output = [ 'resolved' => $resolved_url->getGeneratedUrl(), 'isHomePath' => $is_home_path, @@ -202,6 +255,7 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { 'bundle' => $entity->bundle(), 'id' => $entity->id(), 'uuid' => $entity->uuid(), + 'langcode' => $langcode, ], ]; if ($label_accessible->isAllowed()) { @@ -219,23 +273,37 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { $rt_repo = $this->container->get('jsonapi.resource_type.repository'); $rt = $rt_repo->get($entity_type_id, $entity->bundle()); $type_name = $rt->getTypeName(); - $jsonapi_base_path = $this->container->getParameter('jsonapi.base_path'); - $entry_point_url = Url::fromRoute('jsonapi.resource_list', [], ['absolute' => TRUE])->toString(TRUE); + $jsonapi_base_path = Url::fromRoute( + 'jsonapi.resource_list', + [], + ['language' => $entity->language()] + )->toString(TRUE); + $entry_point_url = Url::fromRoute( + 'jsonapi.resource_list', + [], + [ + 'absolute' => TRUE, + 'language' => $entity->language(), + ] + )->toString(TRUE); $route_name = sprintf('jsonapi.%s.individual', $type_name); $individual = Url::fromRoute( $route_name, [ static::getEntityRouteParameterName($route_name, $entity_type_id) => $entity->uuid(), ], - ['absolute' => TRUE] + [ + 'absolute' => TRUE, + 'language' => $entity->language(), + ] )->toString(TRUE); $response->addCacheableDependency($entry_point_url); $response->addCacheableDependency($individual); $output['jsonapi'] = [ 'individual' => $individual->getGeneratedUrl(), 'resourceName' => $type_name, - 'pathPrefix' => trim($jsonapi_base_path, '/'), - 'basePath' => $jsonapi_base_path, + 'pathPrefix' => trim($jsonapi_base_path->getGeneratedUrl(), '/'), + 'basePath' => $jsonapi_base_path->getGeneratedUrl(), 'entryPoint' => $entry_point_url->getGeneratedUrl(), ]; $output['meta'] = [ @@ -385,6 +453,35 @@ class RouterPathTranslatorSubscriber implements EventSubscriberInterface { return preg_replace(sprintf('/^%s/', $regexp), '', $path); } + /** + * Convert an alias to its source path. + * + * This is a workaround for a bug where matcher fails on aliases prefixed by + * a language prefix when it doesn't match the negotiated language. + * + * @param string $path + * The input path string. + * + * @return string + * The output path string. + */ + protected function getPathFromAlias($path) { + $config = $this->configFactory->get('language.negotiation')->get('url'); + $language_negotiation_url = $this->languageManager->getNegotiator() + ->getNegotiationMethodInstance('language-url'); + $router_request = Request::create($path); + $langcode = $language_negotiation_url->getLangcode($router_request); + $prefix = $config['prefixes'][$langcode] ?? NULL; + if ($prefix && ($path == "/$prefix" || strpos($path, "/$prefix/") === 0)) { + $this->langcode = $langcode; + $path_without_prefix = $language_negotiation_url->processInbound($path, $router_request); + $path = $this->aliasManager->getPathByAlias($path_without_prefix, $langcode); + $path = "/$prefix" . $path; + } + + return $path; + } + /** * Checks if the resolved path is the home path. * diff --git a/tests/src/Functional/DecoupledRouterFunctionalTest.php b/tests/src/Functional/DecoupledRouterFunctionalTest.php index b453ba5..5ab48b9 100644 --- a/tests/src/Functional/DecoupledRouterFunctionalTest.php +++ b/tests/src/Functional/DecoupledRouterFunctionalTest.php @@ -231,13 +231,14 @@ class DecoupledRouterFunctionalTest extends BrowserTestBase { 'bundle' => 'article', 'id' => $node->id(), 'uuid' => $node->uuid(), + 'langcode' => 'en', ], 'label' => $node->label(), 'jsonapi' => [ 'individual' => $this->buildUrl('/jsonapi/node/article/' . $node->uuid()), 'resourceName' => 'node--article', - 'pathPrefix' => 'jsonapi', - 'basePath' => '/jsonapi', + 'pathPrefix' => 'subdirectory/jsonapi', + 'basePath' => '/subdirectory/jsonapi', 'entryPoint' => $this->buildUrl('/jsonapi'), ], 'meta' => [ @@ -310,6 +311,43 @@ class DecoupledRouterFunctionalTest extends BrowserTestBase { $this->assertFalse($output['isHomePath']); } + /** + * Tests path argument with prefix other than negotiated language. + */ + public function testUrlLanguageNegotiation() { + $german = ConfigurableLanguage::createFromLangcode('de'); + $german->save(); + $german_node = $this->createNode([ + 'uid' => ['target_id' => $this->user->id()], + 'type' => 'article', + 'path' => '/hallo-welt', + 'title' => 'Hallo Welt', + 'langcode' => 'de', + 'status' => NodeInterface::PUBLISHED, + ]); + + $this->drupalGet($german_node->toUrl()->toString()); + + $this->assertSession()->pageTextContains('Hallo Welt'); + + $this->drupalGet(Url::fromRoute('decoupled_router.path_translation'), [ + 'query' => [ + 'path' => '/de/hallo-welt', + '_format' => 'json', + ], + ]); + $output = $this->getSession()->getPage()->getContent(); + // Running tests in chromedriver returns html wrapped around the JSON. + if (strpos($output, 'assertSession()->elementExists('css', 'pre')->getHtml(); + } + $output = Json::decode($output); + $this->assertStringEndsWith('/de/hallo-welt', $output['resolved']); + $this->assertSame($german_node->id(), $output['entity']['id']); + $this->assertSame('node--article', $output['jsonapi']['resourceName']); + $this->assertStringEndsWith('/jsonapi/node/article/' . $german_node->uuid(), $output['jsonapi']['individual']); + } + /** * Computes the base path under which the Drupal managed URLs are available. * diff --git a/tests/src/Functional/DecoupledRouterInfoAlterTest.php b/tests/src/Functional/DecoupledRouterInfoAlterTest.php index f13cbab..0946a0f 100644 --- a/tests/src/Functional/DecoupledRouterInfoAlterTest.php +++ b/tests/src/Functional/DecoupledRouterInfoAlterTest.php @@ -90,13 +90,14 @@ class DecoupledRouterInfoAlterTest extends BrowserTestBase { 'uuid' => $node->uuid(), // Result of implementing the hook_decoupled_router_info_alter. 'owner' => $node->getOwner()->uuid(), + 'langcode' => 'en', ], 'label' => $node->label(), 'jsonapi' => [ 'individual' => $this->buildUrl('/jsonapi/node/article/' . $node->uuid()), 'resourceName' => 'node--article', - 'pathPrefix' => 'jsonapi', - 'basePath' => '/jsonapi', + 'pathPrefix' => 'subdirectory/jsonapi', + 'basePath' => '/subdirectory/jsonapi', 'entryPoint' => $this->buildUrl('/jsonapi'), ], 'meta' => [