decoupled_router-2.0.1/src/EventSubscriber/RouterPathTranslatorSubscriber.php
src/EventSubscriber/RouterPathTranslatorSubscriber.php
<?php
namespace Drupal\decoupled_router\EventSubscriber;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\ContentEntityType;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityMalformedException;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Routing\RouteObjectInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Utility\Error;
use Drupal\decoupled_router\PathTranslatorEvent;
use Drupal\path_alias\AliasManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
use Symfony\Component\Routing\Route;
/**
* Event subscriber that processes a path translation with the router info.
*/
class RouterPathTranslatorSubscriber implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The service container.
*
* @var \Symfony\Component\DependencyInjection\ContainerInterface
*/
protected $container;
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The router.
*
* @var \Symfony\Component\Routing\Matcher\UrlMatcherInterface
*/
protected $router;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The alias manager.
*
* @var \Drupal\path_alias\AliasManagerInterface
*/
protected $aliasManager;
/**
* The decoupled_router.settings config.
*/
protected Config $decoupledRouterConfig;
/**
* Determines if a message should be logged if an entity not found.
*/
protected const LOG_ENTITY_NOT_FOUND = TRUE;
/**
* RouterPathTranslatorSubscriber constructor.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* The service container.
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \Symfony\Component\Routing\Matcher\UrlMatcherInterface $router
* The router.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\path_alias\AliasManagerInterface $aliasManager
* The alias manager.
*/
public function __construct(
ContainerInterface $container,
LoggerInterface $logger,
UrlMatcherInterface $router,
ModuleHandlerInterface $module_handler,
ConfigFactoryInterface $config_factory,
AliasManagerInterface $aliasManager,
) {
$this->container = $container;
$this->logger = $logger;
$this->router = $router;
$this->moduleHandler = $module_handler;
$this->configFactory = $config_factory;
$this->aliasManager = $aliasManager;
$this->decoupledRouterConfig = $config_factory->get('decoupled_router.settings');
}
/**
* Processes a path translation request.
*/
public function onPathTranslation(PathTranslatorEvent $event) {
$response = $event->getResponse();
$cacheable_metadata = new CacheableMetadata();
if (!$response instanceof CacheableJsonResponse) {
$this->logger->error('Unable to get the response object for the decoupled router event.');
return;
}
$path = $this->cleanSubdirInPath($event->getPath(), $event->getRequest());
// Preserve the original query string and fragment if any.
$resolved_url_options = UrlHelper::parse($path);
unset($resolved_url_options['path']);
$resolved_url_options['absolute'] = $this->decoupledRouterConfig->get('absolute_resolved_urls');
// If URL is external, we won't perform checks for content in Drupal,
// but assume that it's working.
if (UrlHelper::isExternal($path)) {
$response->setStatusCode(200);
$response->setData([
'resolved' => $path,
'isExternal' => TRUE,
'isHomePath' => FALSE,
]);
return;
}
try {
$match_info = $this->router->match($path);
}
catch (ResourceNotFoundException $exception) {
return;
}
catch (MethodNotAllowedException $exception) {
$response->setStatusCode(403);
return;
}
/** @var \Drupal\Core\Entity\EntityInterface $entity */
[
$entity,
$route_parameter_entity_key,
] = $this->findEntityAndKeys($match_info);
if (!$entity) {
if (static::LOG_ENTITY_NOT_FOUND) {
$this->logger->notice('A route has been found but it has no entity information.');
}
return;
}
$response->addCacheableDependency($entity);
if ($entity->getEntityType() instanceof ContentEntityType) {
$can_view = $entity->access('view', NULL, TRUE);
if (!$can_view->isAllowed()) {
$response->setData([
'message' => 'Access denied for entity.',
'details' => 'This user does not have access to view the resolved entity. Please authenticate and try again.',
]);
$response->setStatusCode(403);
$response->addCacheableDependency($can_view);
return;
}
}
$entity_type_id = $entity->getEntityTypeId();
try {
$canonical_url = $entity->toUrl('canonical', ['absolute' => TRUE])->toString(TRUE);
}
catch (EntityMalformedException $e) {
$response->setData([
'message' => 'Unable to build entity URL.',
'details' => 'A valid entity was found but it was impossible to generate a valid canonical URL for it.',
]);
$response->setStatusCode(500);
// Logging error exceptions.
Error::logException($this->logger, $e);
return;
}
$entity_param = $entity->id();
$resolved_url = Url::fromRoute($match_info[RouteObjectInterface::ROUTE_NAME], [
$route_parameter_entity_key => $entity_param,
], $resolved_url_options);
$resolved_generated_url = $resolved_url->toString(TRUE);
$response->addCacheableDependency($canonical_url);
$response->addCacheableDependency($resolved_generated_url);
$is_home_path = $this->resolvedPathIsHomePath($resolved_url, $cacheable_metadata);
$label_accessible = $entity->access('view label', NULL, TRUE);
$response->addCacheableDependency($label_accessible);
$output = [
'resolved' => $resolved_generated_url->getGeneratedUrl(),
'isExternal' => FALSE,
'isHomePath' => $is_home_path,
'entity' => [
'canonical' => $canonical_url->getGeneratedUrl(),
'type' => $entity_type_id,
'bundle' => $entity->bundle(),
'id' => $entity->id(),
'uuid' => $entity->uuid(),
],
];
if ($label_accessible->isAllowed()) {
$output['label'] = $entity->label();
}
// Allow to alter basic router info.
$this->moduleHandler->invokeAll('decoupled_router_info_alter', [
&$output, ['entity' => $entity],
]);
// If the route is JSON API, it means that JSON API is installed and its
// services can be used.
if ($this->moduleHandler->moduleExists('jsonapi')) {
/** @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $rt_repo */
$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);
$route_name = sprintf('jsonapi.%s.individual', $type_name);
$individual = Url::fromRoute(
$route_name,
[
static::getEntityRouteParameterName($route_name, $entity_type_id) => $entity->uuid(),
],
['absolute' => TRUE]
)->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,
'entryPoint' => $entry_point_url->getGeneratedUrl(),
];
$output['meta'] = [
'deprecated' => [
'jsonapi.pathPrefix' => $this->t(
'This property has been deprecated and will be removed in the next version of Decoupled Router. Use @alternative instead.', ['@alternative' => 'basePath']
),
],
];
}
$response->addCacheableDependency($entity);
$response->addCacheableDependency($this->decoupledRouterConfig);
$response->addCacheableDependency($cacheable_metadata);
$response->setStatusCode(200);
$response->setData($output);
$event->stopPropagation();
}
/**
* Get the underlying entity and the type of ID param enhancer for the routes.
*
* @param array $match_info
* The router match info.
*
* @return array
* The pair of \Drupal\Core\Entity\EntityInterface and bool with the
* underlying entity. It also returns the name of the parameter under which
* the entity lives in the route ('node' vs 'entity').
*/
protected function findEntityAndKeys(array $match_info) {
$entity = NULL;
/** @var \Symfony\Component\Routing\Route $route */
$route = $match_info[RouteObjectInterface::ROUTE_OBJECT];
$route_parameter_entity_key = 'entity';
if (
!empty($match_info['entity']) &&
$match_info['entity'] instanceof EntityInterface
) {
$entity = $match_info['entity'];
}
else {
$entity_type_id = $this->findEntityTypeFromRoute($route);
// @todo $match_info[$entity_type_id] is broken for JSON API 2.x routes.
// Now it will be $match_info[$entity_type_id] for core and
// $match_info['entity'] for JSON API :-(.
if (
!empty($entity_type_id) &&
!empty($match_info[$entity_type_id]) &&
$match_info[$entity_type_id] instanceof EntityInterface
) {
$route_parameter_entity_key = $entity_type_id;
$entity = $match_info[$entity_type_id];
}
}
return [$entity, $route_parameter_entity_key];
}
/**
* Computes the name of the entity route parameter for JSON API routes.
*
* @param string $route_name
* A JSON API route name.
* @param string $entity_type_id
* The corresponding entity type ID.
*
* @return string
* Either 'entity' or $entity_type_id.
*
* @todo Remove this once decoupled_router requires jsonapi >= 8.x-2.0.
*/
protected static function getEntityRouteParameterName($route_name, $entity_type_id) {
static $first;
if (!isset($first)) {
$route_parameters = \Drupal::service('router.route_provider')
->getRouteByName($route_name)
->getOption('parameters');
$first = isset($route_parameters['entity'])
? 'entity'
: $entity_type_id;
return $first;
}
return $first === 'entity'
? 'entity'
: $entity_type_id;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events[PathTranslatorEvent::TRANSLATE][] = ['onPathTranslation'];
return $events;
}
/**
* Extracts the entity type for the route parameters.
*
* If there are more than one parameter, this function will return the first
* one.
*
* @param \Symfony\Component\Routing\Route $route
* The route.
*
* @return string|null
* The entity type ID or NULL if not found.
*/
protected function findEntityTypeFromRoute(Route $route) {
$parameters = (array) $route->getOption('parameters');
// Find the entity type for the first parameter that has one.
return array_reduce($parameters, function ($carry, $parameter) {
if (!$carry && !empty($parameter['type'])) {
$parts = explode(':', $parameter['type']);
// We know that the parameter is for an entity if the type is set to
// 'entity:<entity-type-id>'.
if ($parts[0] === 'entity' && !empty($parts[1])) {
$carry = $parts[1];
}
}
return $carry;
}, NULL);
}
/**
* Removes the subdir prefix from the path.
*
* @param string $path
* The path that can contain the subdir prefix.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request to extract the path prefix from.
*
* @return string
* The clean path.
*/
protected function cleanSubdirInPath($path, Request $request) {
// Remove any possible leading subdir information in case Drupal is
// installed under http://example.com/d8/index.php
$regexp = preg_quote($request->getBasePath(), '/');
return preg_replace(sprintf('/^%s/', $regexp), '', $path);
}
/**
* Checks if the resolved path is the home path.
*
* @param string|\Drupal\Core\Url $resolved_url
* The resolved url from the request.
* @param \Drupal\Core\Cache\CacheableMetadata|null $cacheable_metadata
* The cacheable metadata that will be added to the response.
*
* @return bool
* True if the resolved path is the home path, false otherwise.
*/
protected function resolvedPathIsHomePath(string|Url $resolved_url, ?CacheableMetadata $cacheable_metadata = NULL): bool {
$config = $this->configFactory->get('system.site');
if ($cacheable_metadata) {
$cacheable_metadata->addCacheableDependency($config);
}
else {
@trigger_error('The $cacheable_metadata not being an instance of \Drupal\Core\Cache\CacheableMetadata is deprecated in decoupled_router:2.0.6 and is removed in decoupled_router:3.0.0. Pass in a \Drupal\Core\Cache\CacheableMetadata object instead. See https://www.drupal.org/node/3543536', E_USER_DEPRECATED);
}
$home_path = $this->configFactory->get('system.site')->get('page.front');
if ($resolved_url instanceof Url) {
$home_url = Url::fromUserInput($home_path)->setAbsolute((bool) $resolved_url->getOption('absolute'))->toString();
$resolved_url = $resolved_url->toString();
}
else {
@trigger_error('The $resolved_url not being an instance of \Drupal\Core\Url is deprecated in decoupled_router:2.0.6 and is removed in decoupled_router:3.0.0. Pass in a Url object instead. See https://www.drupal.org/node/3543536', E_USER_DEPRECATED);
$home_url = Url::fromUserInput($home_path, ['absolute' => $this->decoupledRouterConfig->get('absolute_resolved_urls')])->toString(TRUE)->getGeneratedUrl();
}
return $resolved_url === $home_url;
}
}
