Verified Commit 10ea35d5 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3227824 by brianperry, nod_, D34dMan, ravi.shankar, alexpott,...

Issue #3227824 by brianperry, nod_, D34dMan, ravi.shankar, alexpott, johnny5th, bbrala, yogeshmpawar, Suresh Prabhu Parkala, larowlan, joachim, benjifisher, baddysonja, xjm, gabesullice, casey, lauriii: Move the linkset functionality from the decoupled menus contributed module to core's system module
parent 9baae1be
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -643,6 +643,7 @@ linkback
linkgenerator
linkification
linksby
linkset
linktext
lisu
litererally
+3 −60
Original line number Diff line number Diff line
@@ -2,9 +2,7 @@

namespace Drupal\Tests\jsonapi\Functional;

use Behat\Mink\Driver\BrowserKitDriver;
use Drupal\Core\Url;
use GuzzleHttp\RequestOptions;
use Drupal\Tests\ApiRequestTrait;

/**
 * Boilerplate for JSON:API Functional tests' HTTP requests.
@@ -12,63 +10,8 @@
 * @internal
 */
trait JsonApiRequestTestTrait {

  /**
   * Performs a HTTP request. Wraps the Guzzle HTTP client.
   *
   * Why wrap the Guzzle HTTP client? Because we want to keep the actual test
   * code as simple as possible, and hence not require them to specify the
   * 'http_errors = FALSE' request option, nor do we want them to have to
   * convert Drupal Url objects to strings.
   *
   * We also don't want to follow redirects automatically, to ensure these tests
   * are able to detect when redirects are added or removed.
   *
   * @param string $method
   *   HTTP method.
   * @param \Drupal\Core\Url $url
   *   URL to request.
   * @param array $request_options
   *   Request options to apply.
   *
   * @return \Psr\Http\Message\ResponseInterface
   *   The response.
   *
   * @see \GuzzleHttp\ClientInterface::request()
   */
  protected function request($method, Url $url, array $request_options) {
    $this->refreshVariables();
    $request_options[RequestOptions::HTTP_ERRORS] = FALSE;
    $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
    $request_options = $this->decorateWithXdebugCookie($request_options);
    $client = $this->getSession()->getDriver()->getClient()->getClient();
    return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
  }

  /**
   * Adds the Xdebug cookie to the request options.
   *
   * @param array $request_options
   *   The request options.
   *
   * @return array
   *   Request options updated with the Xdebug cookie if present.
   */
  protected function decorateWithXdebugCookie(array $request_options) {
    $session = $this->getSession();
    $driver = $session->getDriver();
    if ($driver instanceof BrowserKitDriver) {
      $client = $driver->getClient();
      foreach ($client->getCookieJar()->all() as $cookie) {
        if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) {
          $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue();
        }
        else {
          $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue();
        }
      }
    }
    return $request_options;
  use ApiRequestTrait {
    makeApiRequest as request;
  }

}
+1 −0
Original line number Diff line number Diff line
linkset_endpoint: false
+8 −0
Original line number Diff line number Diff line
@@ -350,3 +350,11 @@ condition.plugin.request_path:
  mapping:
    pages:
      type: string

system.feature_flags:
  type: config_object
  label: 'System Feature Flags'
  mapping:
    linkset_endpoint:
      type: boolean
      label: 'Enable the menu linkset endpoint'
+281 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\system\Controller;

use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Menu\MenuLinkTreeInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\system\MenuInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

/**
 * Linkset controller.
 *
 * Provides a menu endpoint.
 *
 * @internal
 *   This class's API is internal and it is not intended for extension.
 */
final class LinksetController extends ControllerBase {

  /**
   * Linkset constructor.
   *
   * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menuTree
   *   The menu tree loader service. This is used to load a menu's link
   *   elements so that they can be serialized into a linkset response.
   */
  public function __construct(protected readonly MenuLinkTreeInterface $menuTree) {
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static($container->get('menu.link_tree'));
  }

  /**
   * Serve linkset requests.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   An HTTP request.
   * @param \Drupal\system\MenuInterface $menu
   *   A menu for which to produce a linkset.
   *
   * @return \Drupal\Core\Cache\CacheableJsonResponse
   *   A linkset response.
   */
  public function process(Request $request, MenuInterface $menu) {
    // Load the given menu's tree of elements.
    $tree = $this->loadMenuTree($menu);
    // Get the incoming request URI and parse it so the linkset can use a
    // relative URL for the linkset anchor.
    ['path' => $path, 'query' => $query] = parse_url($request->getUri()) + ['query' => FALSE];
    // Construct a relative URL.
    $anchor = $path . (!empty($query) ? '?' . $query : '');
    $cacheability = CacheableMetadata::createFromObject($menu);
    // Encode the menu tree as links in the application/linkset+json media type
    // and add the machine name of the menu to which they belong.
    $menu_id = $menu->id();
    $links = $this->toLinkTargetObjects($tree, $cacheability);
    foreach ($links as $rel => $target_objects) {
      $links[$rel] = array_map(function (array $target) use ($menu_id) {
        // According to the Linkset specification, this member must be an array
        // since the "machine-name" target attribute is non-standard.
        // See https://tools.ietf.org/html/draft-ietf-httpapi-linkset-08#section-4.2.4.3
        return $target + ['machine-name' => [$menu_id]];
      }, $target_objects);
    }
    $linkset = !empty($tree)
      ? [['anchor' => $anchor] + $links]
      : [];
    $data = ['linkset' => $linkset];
    // Set the response content-type header.
    $headers = ['content-type' => 'application/linkset+json'];
    $response = new CacheableJsonResponse($data, 200, $headers);
    // Attach cacheability metadata to the response.
    $response->addCacheableDependency($cacheability);
    return $response;
  }

  /**
   * Encode a menu tree as link items and capture any cacheability metadata.
   *
   * This method recursively traverses the given menu tree to produce a flat
   * array of link items encoded according the application/linkset+json
   * media type.
   *
   * To preserve hierarchical information, the target attribute contains a
   * `hierarchy` member. Its value is an array containing the position of a link
   * within a particular sub-tree prepended by the positions of its ancestors,
   * and can be used to reconstruct a hierarchical data structure.
   *
   * The reason that a `hierarchy` member is used instead of a `parent` or
   * `children` member is because it is more compact, more suited to the linkset
   * media type, and because it simplifies many menu operations. Specifically:
   *
   * 1. Creating a `parent` member would require each link to have an `id`
   *    in order to have something referenceable by the `parent` member. Reusing
   *    the link plugin IDs would not be viable because it would leak
   *    information about which modules are installed on the site. Therefore,
   *    this ID would have to be invented and would probably end up looking a
   *    lot like the `hierarchy` value. Finally, link IDs would encourage
   *    clients to hardcode the ID instead of using link relation types
   *    appropriately.
   * 2. The linkset media type is not itself hierarchical. This means that
   *    `children` is infeasible without inventing our own Drupal-specific media
   *    type.
   * 3. The `hierarchy` member can be used to efficiently perform tree
   *    operations that would otherwise be more complicated to implement. For
   *    example, by comparing the first X amount of hierarchy levels, you can
   *    find any subtree without writing recursive logic or complicated loops.
   *    Visit the URL below for more examples.
   *
   * The structure of a `hierarchy` value is defined below.
   *
   * A link which is a child of another link will always be prefixed by the
   * exact value of their parent's hierarchy member. For example, if a link /bar
   * is a child of a link /foo and /foo has a hierarchy member with the value
   * ["1"], then the link /bar might have a hierarchy member with the value
   * ["1", "0"]. The link /foo can be said to have depth 1, while the link
   * /bar can be said to have depth 2.
   *
   * Links which have the same parent (or no parent) have their relative order
   * preserved in the final component of the hierarchy value.
   *
   * According to the Linkset specification, each value in the hierarchy array
   * must be a string. See https://tools.ietf.org/html/draft-ietf-httpapi-linkset-08#section-4.2.4.3
   *
   * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree
   *   A tree of menu elements.
   * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $cacheability
   *   An object to capture any cacheability metadata.
   * @param array $hierarchy_ancestors
   *   (Internal use only) The hierarchy value of the parent element
   *   if $tree is a subtree. Do not pass this value.
   *
   * @return array
   *   An array which can be JSON-encoded to represent the given link tree.
   *
   * @see https://www.drupal.org/project/decoupled_menus/issues/3204132#comment-14439385
   */
  protected function toLinkTargetObjects(array $tree, RefinableCacheableDependencyInterface $cacheability, $hierarchy_ancestors = []): array {
    $links = [];
    // Calling array_values() discards any key names so that $index will be
    // numerical.
    foreach (array_values($tree) as $index => $element) {
      // Extract and preserve the access cacheability metadata.
      $element_access = $element->access;
      assert($element_access instanceof AccessResultInterface);
      $cacheability->addCacheableDependency($element_access);
      // If an element is not accessible, it should not be encoded. Its
      // cacheability should be preserved regardless, which is why that is done
      // outside of this conditional.
      if ($element_access->isAllowed()) {
        // Get and generate the URL of the link's target. This can create
        // cacheability metadata also.
        $url = $element->link->getUrlObject();
        $generated_url = $url->toString(TRUE);
        $cacheability = $cacheability->addCacheableDependency($generated_url);
        // Take the hierarchy value for the current element and append it
        // to the link element parent's hierarchy value. See this method's
        // docblock for more context on why this value is the way it is.
        $hierarchy = $hierarchy_ancestors;
        array_push($hierarchy, strval($index));
        $link_options = $element->link->getOptions();
        $link_attributes = ($link_options['attributes'] ?? []);
        $link_rel = $link_attributes['rel'] ?? 'item';
        // Encode the link.
        $link = [
          'href' => $generated_url->getGeneratedUrl(),
          // @todo should this use the "title*" key if it is internationalized?
          // Follow up issue:
          // https://www.drupal.org/project/decoupled_menus/issues/3280735
          'title' => $element->link->getTitle(),
          'hierarchy' => $hierarchy,
        ];
        $this->processCustomLinkAttributes($link, $link_attributes);
        $links[$link_rel][] = $link;
        // Recurse into the element's subtree.
        if (!empty($element->subtree)) {
          // Recursion!
          $links = array_merge_recursive($links, $this->toLinkTargetObjects($element->subtree, $cacheability, $hierarchy));
        }
      }
    }

    return $links;
  }

  /**
   * Process custom link parameters.
   *
   * Since the values for attributes are dynamic and we can't
   * guarantee that they adhere to the linkset specification,
   * we do some custom processing as follows,
   * 1. Transform all of them into an array if
   *    they are not already an array.
   * 2. Transform all non-string values into strings
   *    (e.g. ["42"] instead of [42])
   * 3. Ignore (for now) any keys that are already specified.
   *    Namely: hreflang, media, type, title, and title*.
   * 4. Ensure that custom names do not contain an
   *    asterisk and ignore them if they do.
   * 5. These attributes require special handling. For instance,
   *    these parameters must be strings instead of an array of strings.
   *
   * NOTE: Values which are not object/array are cast to string.
   *
   * @param array $link
   *   Link structure.
   * @param array $attributes
   *   Attributes available for the link.
   */
  private function processCustomLinkAttributes(array &$link, array $attributes = []) {
    $attribute_keys_to_ignore = [
      'hreflang',
      'media',
      'type',
      'title',
      'title*',
    ];

    foreach ($attributes as $key => $value) {
      if (in_array($key, $attribute_keys_to_ignore, TRUE)) {
        continue;
      }
      // Skip the attribute key if it has an asterisk (*).
      if (strpos($key, '*') !== FALSE) {
        continue;
      }
      // Skip the value if it is an object.
      if (is_object($value)) {
        continue;
      }
      // See https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-linkset-03#section-4.2.4.3
      // Values for custom attributes must follow these rules,
      // - Values MUST be array.
      // - Each item in the array MUST be a string.
      if (is_array($value)) {
        $link[$key] = [];
        foreach ($value as $val) {
          if (is_object($val) || is_array($val)) {
            continue;
          }
          $link[$key][] = (string) $val;
        }
      }
      else {
        $link[$key] = [(string) $value];
      }
    }
  }

  /**
   * Loads a menu tree.
   *
   * @param \Drupal\system\MenuInterface $menu
   *   A menu for which a tree should be loaded.
   *
   * @return \Drupal\Core\Menu\MenuLinkTreeElement[]
   *   A menu link tree.
   */
  protected function loadMenuTree(MenuInterface $menu) : array {
    $parameters = new MenuTreeParameters();
    $parameters->onlyEnabledLinks();
    $parameters->setMinDepth(0);
    $tree = $this->menuTree->load($menu->id(), $parameters);
    $manipulators = [
      ['callable' => 'menu.default_tree_manipulators:checkAccess'],
      ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
    ];
    return $this->menuTree->transform($tree, $manipulators);
  }

}
Loading