From 1abc6c7cfd6f7b812bab1bf4ddca121be565ecc4 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 1 May 2024 17:55:11 +0200 Subject: [PATCH] FEATURE: Create query endpoints for node tree --- .../GetChildrenForTreeNodeController.php | 42 ++++ .../GetChildrenForTreeNodeQuery.php | 80 +++++++ .../GetChildrenForTreeNodeQueryHandler.php | 107 +++++++++ .../GetChildrenForTreeNodeQueryResult.php | 35 +++ .../GetTree/Controller/GetTreeController.php | 42 ++++ Classes/Application/GetTree/GetTreeQuery.php | 95 ++++++++ .../GetTree/GetTreeQueryHandler.php | 212 ++++++++++++++++++ .../GetTree/GetTreeQueryResult.php | 35 +++ .../Shared/NodeTypeFilterOption.php | 45 ++++ .../Shared/NodeTypeFilterOptions.php | 64 ++++++ Classes/Application/Shared/TreeNode.php | 65 ++++++ .../Application/Shared/TreeNodeBuilder.php | 84 +++++++ Classes/Application/Shared/TreeNodes.php | 46 ++++ Configuration/Policy.yaml | 18 ++ Configuration/Routes.yaml | 17 ++ Configuration/Settings.Neos.Flow.yaml | 15 ++ 16 files changed, 1002 insertions(+) create mode 100644 Classes/Application/GetChildrenForTreeNode/Controller/GetChildrenForTreeNodeController.php create mode 100644 Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQuery.php create mode 100644 Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryHandler.php create mode 100644 Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryResult.php create mode 100644 Classes/Application/GetTree/Controller/GetTreeController.php create mode 100644 Classes/Application/GetTree/GetTreeQuery.php create mode 100644 Classes/Application/GetTree/GetTreeQueryHandler.php create mode 100644 Classes/Application/GetTree/GetTreeQueryResult.php create mode 100644 Classes/Application/Shared/NodeTypeFilterOption.php create mode 100644 Classes/Application/Shared/NodeTypeFilterOptions.php create mode 100644 Classes/Application/Shared/TreeNode.php create mode 100644 Classes/Application/Shared/TreeNodeBuilder.php create mode 100644 Classes/Application/Shared/TreeNodes.php create mode 100644 Configuration/Policy.yaml create mode 100644 Configuration/Routes.yaml create mode 100644 Configuration/Settings.Neos.Flow.yaml diff --git a/Classes/Application/GetChildrenForTreeNode/Controller/GetChildrenForTreeNodeController.php b/Classes/Application/GetChildrenForTreeNode/Controller/GetChildrenForTreeNodeController.php new file mode 100644 index 0000000..6949033 --- /dev/null +++ b/Classes/Application/GetChildrenForTreeNode/Controller/GetChildrenForTreeNodeController.php @@ -0,0 +1,42 @@ +setDispatched(true); + + $query = $request->getArguments(); + $query = GetChildrenForTreeNodeQuery::fromArray($query); + + $queryResult = $this->queryHandler->handle($query); + + $response->setContentType('application/json'); + $response->setContent(json_encode([ + 'success' => $queryResult + ], JSON_THROW_ON_ERROR)); + } +} diff --git a/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQuery.php b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQuery.php new file mode 100644 index 0000000..1edaef6 --- /dev/null +++ b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQuery.php @@ -0,0 +1,80 @@ +> $dimensionValues + */ + public function __construct( + public readonly string $workspaceName, + public readonly array $dimensionValues, + public readonly NodeAggregateIdentifier $treeNodeId, + public readonly string $nodeTypeFilter, + ) { + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + isset($array['workspaceName']) + or throw new \Exception('Workspace name must be set'); + is_string($array['workspaceName']) + or throw new \Exception('Workspace name must be a string'); + + isset($array['dimensionValues']) + or throw new \Exception('Dimension values must be set'); + is_array($array['dimensionValues']) + or throw new \Exception('Dimension values must be an array'); + + isset($array['treeNodeId']) + or throw new \Exception('Tree node id must be set'); + is_string($array['treeNodeId']) + or throw new \Exception('Tree node id must be a string'); + + !isset($array['nodeTypeFilter']) or is_string($array['nodeTypeFilter']) + or throw new \Exception('Node type filter must be a string'); + + return new self( + workspaceName: $array['workspaceName'], + dimensionValues: $array['dimensionValues'], + treeNodeId: NodeAggregateIdentifier::fromString($array['treeNodeId']), + nodeTypeFilter: $array['nodeTypeFilter'] ?? '', + ); + } + + /** + * @return array + */ + public function getTargetDimensionValues(): array + { + $result = []; + + foreach ($this->dimensionValues as $dimensionName => $fallbackChain) { + $result[$dimensionName] = $fallbackChain[0] ?? ''; + } + + return $result; + } +} diff --git a/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryHandler.php b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryHandler.php new file mode 100644 index 0000000..80af89e --- /dev/null +++ b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryHandler.php @@ -0,0 +1,107 @@ +contentContextFactory->create([ + 'workspaceName' => $query->workspaceName, + 'dimensions' => $query->dimensionValues, + 'targetDimensions' => $query->getTargetDimensionValues(), + 'invisibleContentShown' => true, + 'removedContentShown' => false, + 'inaccessibleContentShown' => true + ]); + + $node = $contentContext->getNodeByIdentifier((string) $query->treeNodeId); + if (!$node instanceof Node) { + throw new \Exception('Forget it!'); + } + + return new GetChildrenForTreeNodeQueryResult( + children: $children = $this->createTreeNodesFromChildrenOfNode($node, $query), + additionalNodeTypeFilterOptions: NodeTypeFilterOptions::fromTreeNodes($children, $this->nodeTypeManager) + ); + } + + private function createTreeNodesFromChildrenOfNode(Node $node, GetChildrenForTreeNodeQuery $query): TreeNodes + { + $items = []; + + foreach ($node->getChildNodes($query->nodeTypeFilter) as $childNode) { + /** @var Node $childNode */ + $items[] = $this->createTreeNodeFromNode($childNode, $query); + } + + return new TreeNodes(...$items); + } + + private function createTreeNodeFromNode(Node $node, GetChildrenForTreeNodeQuery $query): TreeNode + { + return new TreeNode( + nodeAggregateIdentifier: $node->getNodeAggregateIdentifier(), + uri: new Uri('node://' . $node->getNodeAggregateIdentifier()), + icon: $node->getNodeType()->getConfiguration('ui.icon'), + label: $node->getLabel(), + isMatchedByFilter: true, + isDisabled: $node->isHidden(), + isHiddenInMenu: $node->isHiddenInIndex(), + hasScheduledDisabledState: + $node->getHiddenBeforeDateTime() !== null + || $node->getHiddenAfterDateTime() !== null, + hasUnloadedChildren: $node->getNumberOfChildNodes($query->nodeTypeFilter) > 0, + nodeTypeNames: iterator_to_array( + $this->getAllNonAbstractSuperTypesOf($node->getNodeType()), + false + ), + children: new TreeNodes(), + ); + } + + /** + * @return \Traversable + */ + private function getAllNonAbstractSuperTypesOf(NodeType $nodeType): \Traversable + { + if (!$nodeType->isAbstract()) { + yield NodeTypeName::fromString($nodeType->getName()); + } + + foreach ($nodeType->getDeclaredSuperTypes() as $superType) { + yield from $this->getAllNonAbstractSuperTypesOf($superType); + } + } +} diff --git a/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryResult.php b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryResult.php new file mode 100644 index 0000000..47e835c --- /dev/null +++ b/Classes/Application/GetChildrenForTreeNode/GetChildrenForTreeNodeQueryResult.php @@ -0,0 +1,35 @@ +setDispatched(true); + + $query = $request->getArguments(); + $query = GetTreeQuery::fromArray($query); + + $queryResult = $this->queryHandler->handle($query); + + $response->setContentType('application/json'); + $response->setContent(json_encode([ + 'success' => $queryResult + ], JSON_THROW_ON_ERROR)); + } +} diff --git a/Classes/Application/GetTree/GetTreeQuery.php b/Classes/Application/GetTree/GetTreeQuery.php new file mode 100644 index 0000000..a0cdb5a --- /dev/null +++ b/Classes/Application/GetTree/GetTreeQuery.php @@ -0,0 +1,95 @@ +> $dimensionValues + */ + public function __construct( + public readonly string $workspaceName, + public readonly array $dimensionValues, + public readonly NodePath $startingPoint, + public readonly int $loadingDepth, + public readonly string $nodeTypeFilter, + public readonly string $searchTerm, + ) { + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + isset($array['workspaceName']) + or throw new \Exception('Workspace name must be set'); + is_string($array['workspaceName']) + or throw new \Exception('Workspace name must be a string'); + + isset($array['dimensionValues']) + or throw new \Exception('Dimension values must be set'); + is_array($array['dimensionValues']) + or throw new \Exception('Dimension values must be an array'); + + isset($array['startingPoint']) + or throw new \Exception('Starting point must be set'); + is_string($array['startingPoint']) + or throw new \Exception('Starting point must be a string'); + + isset($array['loadingDepth']) + or throw new \Exception('Loading depth must be set'); + if (is_string($array['loadingDepth'])) { + $array['loadingDepth'] = (int) $array['loadingDepth']; + } + is_int($array['loadingDepth']) + or throw new \Exception('Loading depth must be an integer'); + + !isset($array['nodeTypeFilter']) or is_string($array['nodeTypeFilter']) + or throw new \Exception('Node type filter must be a string'); + + !isset($array['searchTerm']) or is_string($array['searchTerm']) + or throw new \Exception('Search term must be a string'); + + return new self( + workspaceName: $array['workspaceName'], + dimensionValues: $array['dimensionValues'], + startingPoint: NodePath::fromString($array['startingPoint']), + loadingDepth: $array['loadingDepth'], + nodeTypeFilter: $array['nodeTypeFilter'] ?? '', + searchTerm: $array['searchTerm'] ?? '', + ); + } + + /** + * @return array + */ + public function getTargetDimensionValues(): array + { + $result = []; + + foreach ($this->dimensionValues as $dimensionName => $fallbackChain) { + $result[$dimensionName] = $fallbackChain[0] ?? ''; + } + + return $result; + } +} diff --git a/Classes/Application/GetTree/GetTreeQueryHandler.php b/Classes/Application/GetTree/GetTreeQueryHandler.php new file mode 100644 index 0000000..578e649 --- /dev/null +++ b/Classes/Application/GetTree/GetTreeQueryHandler.php @@ -0,0 +1,212 @@ +contentContextFactory->create([ + 'workspaceName' => $query->workspaceName, + 'dimensions' => $query->dimensionValues, + 'targetDimensions' => $query->getTargetDimensionValues(), + 'invisibleContentShown' => true, + 'removedContentShown' => false, + 'inaccessibleContentShown' => true + ]); + + $rootNode = $contentContext->getNode((string) $query->startingPoint); + if (!$rootNode instanceof Node) { + throw new \Exception('Forget it!'); + } + + return new GetTreeQueryResult( + root: $tree = empty($query->searchTerm) + ? $this->createTreeNodeFromNode($rootNode, $query, $query->loadingDepth) + : $this->performSearch($rootNode, $query), + nodeTypeFilterOptions: NodeTypeFilterOptions::fromTreeNode($tree, $this->nodeTypeManager), + ); + } + + private function performSearch(Node $rootNode, GetTreeQuery $query): TreeNode + { + $allowedNodeTypeNames = iterator_to_array( + $this->getAllowedNodeTypeNamesAccordingToNodeTypeFilter($query->nodeTypeFilter), + false + ); + /** @var NodeSearchService $nodeSearchService */ + $nodeSearchService = $this->nodeSearchService; + $matchingNodes = $nodeSearchService->findByProperties( + $query->searchTerm, + $allowedNodeTypeNames, + $rootNode->getContext(), + $rootNode + ); + + $treeNodeBuildersByNodeAggregateIdentifier = [ + (string) $rootNode->getNodeAggregateIdentifier() => + $rootTreeNodeBuilder = $this->createTreeNodeBuilderFromNode($rootNode) + ]; + + foreach ($matchingNodes as $matchingNode) { + /** @var Node $matchingNode */ + $treeNodeBuilder = $treeNodeBuildersByNodeAggregateIdentifier[(string) $matchingNode->getNodeAggregateIdentifier()] = + $this->createTreeNodeBuilderFromNode($matchingNode); + $treeNodeBuilder->setIsMatchedByFilter(true); + + $subject = $matchingNode; + do { + /** @var null|Node $parentNode */ + $parentNode = $subject->getParent(); + if (!$parentNode) { + throw new \Exception('What is this?'); + } + + $parentTreeNodeBuilder = + $treeNodeBuildersByNodeAggregateIdentifier[(string) $parentNode->getNodeAggregateIdentifier()] + ??= $this->createTreeNodeBuilderFromNode($parentNode); + $parentTreeNodeBuilder->addChild($treeNodeBuilder); + + $subject = $parentNode; + $treeNodeBuilder = $parentTreeNodeBuilder; + } while ( + !$subject->getNodeAggregateIdentifier() + ->equals($rootNode->getNodeAggregateIdentifier()) + ); + } + + return $rootTreeNodeBuilder->build(); + } + + private function createTreeNodeBuilderFromNode(Node $node): TreeNodeBuilder + { + return new TreeNodeBuilder( + nodeAggregateIdentifier: $node->getNodeAggregateIdentifier(), + uri: new Uri('node://' . $node->getNodeAggregateIdentifier()), + icon: $node->getNodeType()->getConfiguration('ui.icon'), + label: $node->getLabel(), + isMatchedByFilter: false, + isDisabled: $node->isHidden(), + isHiddenInMenu: $node->isHiddenInIndex(), + hasScheduledDisabledState: + $node->getHiddenBeforeDateTime() !== null + || $node->getHiddenAfterDateTime() !== null, + hasUnloadedChildren: false, + nodeTypeNames: iterator_to_array( + $this->getAllNonAbstractSuperTypesOf($node->getNodeType()), + false + ), + children: [], + ); + } + + /** + * @return \Traversable + */ + private function getAllowedNodeTypeNamesAccordingToNodeTypeFilter(string $nodeTypeFilter): \Traversable + { + $nodeTypeConstraints = $this->nodeTypeConstraintFactory->parseFilterString($nodeTypeFilter); + + foreach ($this->nodeTypeManager->getNodeTypes(false) as $nodeType) { + $nodeTypeName = $nodeType->getName(); + if ($nodeTypeConstraints->matches(NodeTypeName::fromString($nodeTypeName))) { + yield $nodeTypeName; + } + } + } + + private function createTreeNodeFromNode(Node $node, GetTreeQuery $query, int $remainingDepth): TreeNode + { + return new TreeNode( + nodeAggregateIdentifier: $node->getNodeAggregateIdentifier(), + uri: new Uri('node://' . $node->getNodeAggregateIdentifier()), + icon: $node->getNodeType()->getConfiguration('ui.icon'), + label: $node->getLabel(), + isMatchedByFilter: true, + isDisabled: $node->isHidden(), + isHiddenInMenu: $node->isHiddenInIndex(), + hasScheduledDisabledState: + $node->getHiddenBeforeDateTime() !== null + || $node->getHiddenAfterDateTime() !== null, + hasUnloadedChildren: + $remainingDepth === 0 + && $node->getNumberOfChildNodes($query->nodeTypeFilter) > 0, + nodeTypeNames: iterator_to_array( + $this->getAllNonAbstractSuperTypesOf($node->getNodeType()), + false + ), + children: $this->createTreeNodesFromChildrenOfNode($node, $query, $remainingDepth), + ); + } + + private function createTreeNodesFromChildrenOfNode(Node $node, GetTreeQuery $query, int $remainingDepth): TreeNodes + { + if ($remainingDepth === 0) { + return new TreeNodes(); + } + + $items = []; + + foreach ($node->getChildNodes($query->nodeTypeFilter) as $childNode) { + /** @var Node $childNode */ + $items[] = $this->createTreeNodeFromNode($childNode, $query, $remainingDepth - 1); + } + + return new TreeNodes(...$items); + } + + /** + * @return \Traversable + */ + private function getAllNonAbstractSuperTypesOf(NodeType $nodeType): \Traversable + { + if (!$nodeType->isAbstract()) { + yield NodeTypeName::fromString($nodeType->getName()); + } + + foreach ($nodeType->getDeclaredSuperTypes() as $superType) { + yield from $this->getAllNonAbstractSuperTypesOf($superType); + } + } +} diff --git a/Classes/Application/GetTree/GetTreeQueryResult.php b/Classes/Application/GetTree/GetTreeQueryResult.php new file mode 100644 index 0000000..bd8e371 --- /dev/null +++ b/Classes/Application/GetTree/GetTreeQueryResult.php @@ -0,0 +1,35 @@ +getName()), + icon: $nodeType->getConfiguration('ui.icon'), + label: $nodeType->getConfiguration('ui.label'), + ); + } + + public function jsonSerialize(): mixed + { + return get_object_vars($this); + } +} diff --git a/Classes/Application/Shared/NodeTypeFilterOptions.php b/Classes/Application/Shared/NodeTypeFilterOptions.php new file mode 100644 index 0000000..a0354a1 --- /dev/null +++ b/Classes/Application/Shared/NodeTypeFilterOptions.php @@ -0,0 +1,64 @@ +items = array_values($items); + } + + public static function fromTreeNode(TreeNode $treeNode, NodeTypeManager $nodeTypeManager): self + { + $nodeTypeNames = iterator_to_array($treeNode->getNodeTypeNamesForFilterRecursively()); + return self::fromNodeTypeNames($nodeTypeNames, $nodeTypeManager); + } + + public static function fromTreeNodes(TreeNodes $treeNodes, NodeTypeManager $nodeTypeManager): self + { + $nodeTypeNames = iterator_to_array($treeNodes->getNodeTypeNamesForFilterRecursively()); + return self::fromNodeTypeNames($nodeTypeNames, $nodeTypeManager); + } + + /** + * @param array $nodeTypeNames + */ + private static function fromNodeTypeNames(array $nodeTypeNames, NodeTypeManager $nodeTypeManager): self + { + $items = []; + + foreach ($nodeTypeNames as $nodeTypeName) { + $nodeType = $nodeTypeManager->getNodeType((string) $nodeTypeName); + $items[] = NodeTypeFilterOption::fromNodeType($nodeType); + } + + return new self(...$items); + } + + public function jsonSerialize(): mixed + { + return $this->items; + } +} diff --git a/Classes/Application/Shared/TreeNode.php b/Classes/Application/Shared/TreeNode.php new file mode 100644 index 0000000..40ac527 --- /dev/null +++ b/Classes/Application/Shared/TreeNode.php @@ -0,0 +1,65 @@ + + */ + public function getNodeTypeNamesForFilterRecursively(): \Traversable + { + if ($this->isMatchedByFilter) { + foreach ($this->nodeTypeNames as $nodeTypeName) { + yield (string) $nodeTypeName => $nodeTypeName; + } + } + + yield from $this->children->getNodeTypeNamesForFilterRecursively(); + } +} diff --git a/Classes/Application/Shared/TreeNodeBuilder.php b/Classes/Application/Shared/TreeNodeBuilder.php new file mode 100644 index 0000000..a430c34 --- /dev/null +++ b/Classes/Application/Shared/TreeNodeBuilder.php @@ -0,0 +1,84 @@ +isMatchedByFilter = $value; + return $this; + } + + public function addChild(TreeNodeBuilder $childBuilder): self + { + $this->children[] = $childBuilder; + return $this; + } + + public function build(): TreeNode + { + return new TreeNode( + nodeAggregateIdentifier: $this->nodeAggregateIdentifier, + uri: $this->uri, + icon: $this->icon, + label: $this->label, + isMatchedByFilter: $this->isMatchedByFilter, + isDisabled: $this->isDisabled, + isHiddenInMenu: $this->isHiddenInMenu, + hasScheduledDisabledState: $this->hasScheduledDisabledState, + hasUnloadedChildren: $this->hasUnloadedChildren, + nodeTypeNames: $this->nodeTypeNames, + children: $this->buildChildren(), + ); + } + + private function buildChildren(): TreeNodes + { + $items = []; + + foreach ($this->children as $childBuilder) { + $items[] = $childBuilder->build(); + } + + return new TreeNodes(...$items); + } +} diff --git a/Classes/Application/Shared/TreeNodes.php b/Classes/Application/Shared/TreeNodes.php new file mode 100644 index 0000000..d4c4798 --- /dev/null +++ b/Classes/Application/Shared/TreeNodes.php @@ -0,0 +1,46 @@ +items = array_values($items); + } + + public function jsonSerialize(): mixed + { + return $this->items; + } + + /** + * @return \Traversable + */ + public function getNodeTypeNamesForFilterRecursively(): \Traversable + { + foreach ($this->items as $childTreeNode) { + yield from $childTreeNode->getNodeTypeNamesForFilterRecursively(); + } + } +} diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml new file mode 100644 index 0000000..067ae21 --- /dev/null +++ b/Configuration/Policy.yaml @@ -0,0 +1,18 @@ +# # +# Security policy for the Sitegeist.Archaeopteryx package # +# # +--- +privilegeTargets: + + 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': + + 'Sitegeist.Archaeopteryx:ApiAccess': + matcher: 'method(Sitegeist\Archaeopteryx\Application\.*\Controller\.*Controller->processRequest())' + +roles: + + 'Neos.Neos:AbstractEditor': + privileges: + - + privilegeTarget: 'Sitegeist.Archaeopteryx:ApiAccess' + permission: GRANT diff --git a/Configuration/Routes.yaml b/Configuration/Routes.yaml new file mode 100644 index 0000000..d2eaded --- /dev/null +++ b/Configuration/Routes.yaml @@ -0,0 +1,17 @@ +- + name: 'GetChildrenForTreeNode Query' + uriPattern: 'sitegeist/archaeopteryx/get-children-for-tree-node' + defaults: + '@package': 'Sitegeist.Archaeopteryx' + '@subpackage': 'Application\GetChildrenForTreeNode' + '@controller': 'GetChildrenForTreeNode' + '@action': 'ignored' + +- + name: 'GetTree Query' + uriPattern: 'sitegeist/archaeopteryx/get-tree' + defaults: + '@package': 'Sitegeist.Archaeopteryx' + '@subpackage': 'Application\GetTree' + '@controller': 'GetTree' + '@action': 'ignored' diff --git a/Configuration/Settings.Neos.Flow.yaml b/Configuration/Settings.Neos.Flow.yaml new file mode 100644 index 0000000..5849ee8 --- /dev/null +++ b/Configuration/Settings.Neos.Flow.yaml @@ -0,0 +1,15 @@ +Neos: + Flow: + mvc: + routes: + 'Sitegeist.Archaeopteryx': + position: 'before Neos.Neos' + security: + authentication: + providers: + 'Neos.Neos:Backend': + requestPatterns: + 'Sitegeist.Archaeopteryx:ApiControllers': + pattern: ControllerObjectName + patternOptions: + controllerObjectNamePattern: 'Sitegeist\Archaeopteryx\Application\.*\Controller\.*Controller'