diff --git a/modules/jsonapi_menu_items_tree/README.md b/modules/jsonapi_menu_items_tree/README.md new file mode 100644 index 0000000000000000000000000000000000000000..34d35db05382ff1a126d8ad6e44dc81c8a187f2a --- /dev/null +++ b/modules/jsonapi_menu_items_tree/README.md @@ -0,0 +1,155 @@ +# Urban Insight JSON API Menu Tree + +> Adds a JSON API resource to expose menu items in their original tree structure: `/jsonapi/menu_items_tree/{menu}` + +Other JSON:API resources that also expose menu items do so by flattening all data and listing all menu items in a single-level list. Then they put the reference to the parent element in a `parent` attribute. This is how it is done by resources like: + +- **The default JSON:API endpoint for the Menu Link Content entities:** `/jsonapi/menu_link_content/menu_link_content?filter[menu_name]=main` + +- **[The contrib module JSON:API Menu Items](https://www.drupal.org/project/jsonapi_menu_items)**, which simplifies the obtention of menu items, providing a custom endpoint and the possibility of filtering by link depth, parents, etc: `/jsonapi/menu_items/main` + +Let's say you have 4 links A, B, C and D; where A is a first-level element and the parent of B; B in turn is the parent of C, and D is a first-level element, sibling of A and without children. + +* A + * B + * C +* D + +Using any of the default JSON:API endpoint for the Menu Link Content entities:** `/jsonapi/menu_link_content/menu_link_content?filter[menu_name]=main` or the resource provided by the parent module (`/jsonapi/menu_items/main`), the result in the response will be a flattened representation of the menu tree similar to this: + +``` +"data": [ + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:A", + "attributes": { + "menu_name": "main", + "title": "A", + "url": "/node/1", + "parent": "" + } + }, + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:B", + "attributes": { + "menu_name": "main", + "title": "B", + "url": "/node/2", + "parent": "menu_link_content:A" + } + }, + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:C", + "attributes": { + "menu_name": "main", + "title": "C", + "url": "/node/3", + "parent": "menu_link_content:B" + } + }, + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:D", + "attributes": { + "menu_name": "main", + "title": "D", + "url": "/node/4", + "parent": "" + } + } +] +``` +By using the JSON:API resource created by this module, you'll get a tree structure instead of a flattened array, which means that external applications don't have to deal with hierarchy, which can make menu building easier in the client app: + +``` +"data": [ + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:A", + "attributes": { + "menu_name": "main", + "title": "A", + "url": "/node/1", + "parent": "", + "subTree": [ + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:B", + "attributes": { + "menu_name": "main", + "title": "B", + "url": "/node/2", + "parent": "menu_link_content:A", + "children": [ + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:C", + "attributes": { + "menu_name": "main", + "title": "C", + "url": "/node/3", + "parent": "menu_link_content:B" + } + }, + ] + } + }, + ] + } + }, + { + "type": "menu_link_content--menu_link_content", + "id": "menu_link_content:D", + "attributes": { + "menu_name": "main", + "title": "D", + "url": "/node/4", + "parent": "" + } + } +] +``` + +## Features + +- Supports user and system created menu items. +- Supports `menu_link_content` and [menu_link_config](https://www.drupal.org/project/menu_link_config) menu items. +- Supports filtering by depth, parents and custom query conditions. + +## Pending + +- Add support for fields added to menu links via [Menu Item Extras](https://www.drupal.org/project/menu_item_extras) (includes). + +## Filters + +- **min_depth** + + Sets the minimum depth of menu links in the resulting tree relative to the root. + + Example: `?filter[min_depth]=2` + +- **max_depth** + + Sets the maximum depth of menu links in the resulting tree relative to the root. + + Example: `?filter[max_depth]=2` + +- **parent** + + Sets a root for menu tree loading. + + Example: `?filter[parent]=system.admin` + +- **parents** + + Adds parent menu links IDs to restrict the tree. + + Example: `?filter[parents]=system.admin,system.admin_structure` + +- **conditions[]** + + Adds a custom query condition. + + Example: `?filter[conditions][provider][value]=system` diff --git a/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.info.yml b/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..c2ff79bef11231e1dadf9cd2e51b573b30086cc5 --- /dev/null +++ b/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.info.yml @@ -0,0 +1,7 @@ +name: JSON:API Menu Tree +type: module +description: Provides a JSON API resource to expose a menu tree +package: Custom +core_version_requirement: ^9 || ^10 +dependencies: + - jsonapi_menu_items:jsonapi_menu_items \ No newline at end of file diff --git a/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.routing.yml b/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..30dfe392de3049dad68f0642b29ca070e191c5cf --- /dev/null +++ b/modules/jsonapi_menu_items_tree/jsonapi_menu_items_tree.routing.yml @@ -0,0 +1,10 @@ +jsonapi_menu_items_tree.menu_items_tree: + path: '/%jsonapi%/menu_items_tree/{menu}' + defaults: + _jsonapi_resource: '\Drupal\jsonapi_menu_items_tree\Resource\MenuTreeResource' + options: + parameters: + menu: + type: entity:menu + requirements: + _access: 'TRUE' diff --git a/modules/jsonapi_menu_items_tree/src/Resource/MenuTreeResource.php b/modules/jsonapi_menu_items_tree/src/Resource/MenuTreeResource.php new file mode 100644 index 0000000000000000000000000000000000000000..455df46ce736dc99d86e13ab92f84942d0486859 --- /dev/null +++ b/modules/jsonapi_menu_items_tree/src/Resource/MenuTreeResource.php @@ -0,0 +1,150 @@ +<?php + +namespace Drupal\jsonapi_menu_items_tree\Resource; + +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\GeneratedUrl; +use Drupal\jsonapi\JsonApiResource\LinkCollection; +use Drupal\jsonapi\JsonApiResource\ResourceObject; +use Drupal\system\MenuInterface; +use Drupal\jsonapi_menu_items\Resource\MenuItemsResource; + +/** + * Processes a request for a menu and delivers a processed menu tree structure. + * + * @internal + */ +class MenuTreeResource extends MenuItemsResource { + + /** + * Overrides the parent method to provide a custom menu tree structure. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree + * The menu tree. + * @param array $items + * The already created items. + * @param \Drupal\Core\Cache\CacheableMetadata $cache + * The cacheable metadata. + * @param \Drupal\system\MenuInterface $menu + * The menu that the links belong to. + */ + protected function getMenuItems(array $tree, array &$items, CacheableMetadata $cache, MenuInterface $menu) { + $items = []; + foreach ($tree as $menu_link) { + + if ($menu_link->access !== NULL && !$menu_link->access instanceof AccessResultInterface) { + throw new \DomainException('MenuLinkTreeElement::access must be either NULL or an AccessResultInterface object.'); + } + + if ($menu_link->access instanceof AccessResultInterface) { + $cache->merge(CacheableMetadata::createFromObject($menu_link->access)); + } + + // Only return accessible links. + if ($menu_link->access instanceof AccessResultInterface && !$menu_link->access->isAllowed()) { + continue; + } + + $id = $menu_link->link->getPluginId(); + [$plugin] = explode(':', $id, 2); + if ($plugin === 'menu_link_config') { + $resource_type = $this->resourceTypeRepository->get('menu_link_config', 'menu_link_config'); + } + else { + $resource_type = $this->resourceTypeRepository->get('menu_link_content', $menu_link->link->getMenuName()); + if ($resource_type === NULL) { + $resource_type = $this->resourceTypeRepository->get('menu_link_content', 'menu_link_content'); + } + } + + $url = $menu_link->link->getUrlObject()->toString(TRUE); + assert($url instanceof GeneratedUrl); + $cache->addCacheableDependency($url); + + $links = new LinkCollection([]); + + $resource_object_cacheability = new CacheableMetadata(); + $resource_object_cacheability->addCacheableDependency($menu_link->access); + $resource_object_cacheability->addCacheableDependency($cache); + + $fields = $this->buildMenuLinkSubtree($menu_link, $resource_object_cacheability); + $items[$id] = new ResourceObject($resource_object_cacheability, $resource_type, $id, NULL, $fields, $links); + } + } + + /** + * Recursively generates the data structure for a menu link and its children. + * + * Each child link is nested under the `subTree` key of its parent link, + * creating a tree-like representation of the menu. + * + * @param array $menuLink + * The menu link data to process. + * @param \Drupal\Core\Cache\CacheableMetadata $cache + * The cacheable metadata object to merge with the menu link's cache. + * @param int $depth + * The current depth in the menu tree. + * + * @return array + * A structured array representing the menu link and its nested children. + */ + public function buildMenuLinkSubtree($menu_link, CacheableMetadata $cache, int $depth = 0) { + + if ($menu_link->access !== NULL && !$menu_link->access instanceof AccessResultInterface) { + throw new \DomainException('MenuLinkTreeElement::access must be either NULL or an AccessResultInterface object.'); + } + + if ($menu_link->access instanceof AccessResultInterface) { + $cache->merge(CacheableMetadata::createFromObject($menu_link->access)); + } + + // Only build data for accessible links. + if ($menu_link->access instanceof AccessResultInterface && !$menu_link->access->isAllowed()) { + return []; + } + + $url = $menu_link->link->getUrlObject()->toString(TRUE); + assert($url instanceof GeneratedUrl); + $cache->addCacheableDependency($url); + $cache->addCacheableDependency($menu_link->access); + + $fields = [ + 'description' => $menu_link->link->getDescription(), + 'enabled' => $menu_link->link->isEnabled(), + 'expanded' => $menu_link->link->isExpanded(), + 'menu_name' => $menu_link->link->getMenuName(), + 'meta' => $menu_link->link->getMetaData(), + 'options' => $menu_link->link->getOptions(), + 'parent' => $menu_link->link->getParent(), + 'provider' => $menu_link->link->getProvider(), + 'route' => [ + 'name' => $menu_link->link->getRouteName(), + 'parameters' => $menu_link->link->getRouteParameters(), + ], + 'title' => (string) $menu_link->link->getTitle(), + 'url' => $url->getGeneratedUrl(), + 'weight' => (int) $menu_link->link->getWeight(), + 'depth' => $depth, + ]; + + if ($menu_link->subtree) { + $depth++; + foreach ($menu_link->subtree as $link) { + /** @var \Drupal\Core\Menu\MenuLinkTreeElement $link */ + if ($link->access instanceof AccessResultInterface && !$link->access->isAllowed()) { + continue; + } + $provider = $link->link->getProvider(); + $child_data = [ + "type" => $provider . '--' . $provider, + "id" => $link->link->getPluginId(), + "attributes" => $this->buildMenuLinkSubtree($link, $cache, $depth), + ]; + $fields['subTree'][] = $child_data; + } + } + return $fields; + } + +} diff --git a/src/Resource/MenuItemsResource.php b/src/Resource/MenuItemsResource.php index 045cc8d31f0651036426c3bee73a0b0fbe8d7836..bf0d3223099bc02998c1f4af9e19ada4202c44c2 100644 --- a/src/Resource/MenuItemsResource.php +++ b/src/Resource/MenuItemsResource.php @@ -30,7 +30,7 @@ use Symfony\Component\Routing\Route; * * @internal */ -final class MenuItemsResource extends ResourceBase implements ContainerInjectionInterface { +class MenuItemsResource extends ResourceBase implements ContainerInjectionInterface { /** * A list of menu items. @@ -44,35 +44,35 @@ final class MenuItemsResource extends ResourceBase implements ContainerInjection * * @var \Drupal\system\MenuInterface */ - private $menuLinkTree; + protected $menuLinkTree; /** * The entity type manager service. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ - private $entityTypeManager; + protected $entityTypeManager; /** * The entity field manager service. * * @var \Drupal\Core\Entity\EntityFieldManagerInterface */ - private $entityFieldManager; + protected $entityFieldManager; /** * The cache backend. * * @var \Drupal\Core\Cache\CacheBackendInterface */ - private $cache; + protected $cache; /** * The entity repository. * * @var \Drupal\Core\Entity\EntityRepositoryInterface */ - private $entityRepository; + protected $entityRepository; /** * Construct a new MenuItemsResource object. @@ -100,7 +100,7 @@ final class MenuItemsResource extends ResourceBase implements ContainerInjection * {@inheritDoc} */ public static function create(ContainerInterface $container) { - return new self( + return new static( $container->get('menu.link_tree'), $container->get('entity_type.manager'), $container->get('entity_field.manager'),