vendor/shopware/core/Content/Category/SalesChannel/NavigationRoute.php line 212

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Content\Category\SalesChannel;
  3. use Doctrine\DBAL\Connection;
  4. use OpenApi\Annotations as OA;
  5. use Shopware\Core\Content\Category\CategoryCollection;
  6. use Shopware\Core\Content\Category\CategoryEntity;
  7. use Shopware\Core\Content\Category\Exception\CategoryNotFoundException;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\CountAggregation;
  11. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Bucket\TermsResult;
  12. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  13. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter;
  14. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  15. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
  16. use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
  17. use Shopware\Core\Framework\Routing\Annotation\Entity;
  18. use Shopware\Core\Framework\Routing\Annotation\RouteScope;
  19. use Shopware\Core\Framework\Routing\Annotation\Since;
  20. use Shopware\Core\Framework\Uuid\Uuid;
  21. use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
  22. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  23. use Symfony\Component\HttpFoundation\Request;
  24. use Symfony\Component\Routing\Annotation\Route;
  25. /**
  26.  * @Route(defaults={"_routeScope"={"store-api"}})
  27.  */
  28. class NavigationRoute extends AbstractNavigationRoute
  29. {
  30.     /**
  31.      * @var SalesChannelRepositoryInterface
  32.      */
  33.     private $categoryRepository;
  34.     /**
  35.      * @var Connection
  36.      */
  37.     private $connection;
  38.     /**
  39.      * @internal
  40.      */
  41.     public function __construct(
  42.         Connection $connection,
  43.         SalesChannelRepositoryInterface $repository
  44.     ) {
  45.         $this->categoryRepository $repository;
  46.         $this->connection $connection;
  47.     }
  48.     public function getDecorated(): AbstractNavigationRoute
  49.     {
  50.         throw new DecorationPatternException(self::class);
  51.     }
  52.     /**
  53.      * @Since("6.2.0.0")
  54.      * @Entity("category")
  55.      * @OA\Post(
  56.      *      path="/navigation/{requestActiveId}/{requestRootId}",
  57.      *      summary="Fetch a navigation menu",
  58.      *      description="This endpoint returns categories that can be used as a page navigation. You can either return them as a tree or as a flat list. You can also control the depth of the tree.
  59. Instead of passing uuids, you can also use one of the following aliases for the activeId and rootId parameters to get the respective navigations of your sales channel.
  60. * main-navigation
  61. * service-navigation
  62. * footer-navigation",
  63.      *      operationId="readNavigation",
  64.      *      tags={"Store API", "Category"},
  65.      *      @OA\Parameter(name="Api-Basic-Parameters"),
  66.      *      @OA\Parameter(
  67.      *          name="sw-include-seo-urls",
  68.      *          description="Instructs Shopware to try and resolve SEO URLs for the given navigation item",
  69.      *          @OA\Schema(type="boolean"),
  70.      *          in="header",
  71.      *          required=false
  72.      *      ),
  73.      *      @OA\Parameter(
  74.      *          name="requestActiveId",
  75.      *          description="Identifier of the active category in the navigation tree (if not used, just set to the same as rootId).",
  76.      *          @OA\Schema(type="string", pattern="^[0-9a-f]{32}$"),
  77.      *          in="path",
  78.      *          required=true
  79.      *      ),
  80.      *      @OA\Parameter(
  81.      *          name="requestRootId",
  82.      *          description="Identifier of the root category for your desired navigation tree. You can use it to fetch sub-trees of your navigation tree.",
  83.      *          @OA\Schema(type="string", pattern="^[0-9a-f]{32}$"),
  84.      *          in="path",
  85.      *          required=true
  86.      *      ),
  87.      *      @OA\RequestBody(
  88.      *          required=true,
  89.      *          @OA\JsonContent(
  90.      *              @OA\Property(
  91.      *                  property="depth",
  92.      *                  description="Determines the depth of fetched navigation levels.",
  93.      *                  @OA\Schema(type="integer", default="2")
  94.      *              ),
  95.      *              @OA\Property(
  96.      *                  property="buildTree",
  97.      *                  description="Return the categories as a tree or as a flat list.",
  98.      *                  @OA\Schema(type="boolean", default="true")
  99.      *              )
  100.      *          )
  101.      *      ),
  102.      *      @OA\Response(
  103.      *          response="200",
  104.      *          description="All available navigations",
  105.      *          @OA\JsonContent(ref="#/components/schemas/NavigationRouteResponse")
  106.      *     )
  107.      * )
  108.      * @Route("/store-api/navigation/{activeId}/{rootId}", name="store-api.navigation", methods={"GET", "POST"})
  109.      */
  110.     public function load(
  111.         string $activeId,
  112.         string $rootId,
  113.         Request $request,
  114.         SalesChannelContext $context,
  115.         Criteria $criteria
  116.     ): NavigationRouteResponse {
  117.         $depth $request->query->getInt('depth'$request->request->getInt('depth'2));
  118.         $metaInfo $this->getCategoryMetaInfo($activeId$rootId);
  119.         $active $this->getMetaInfoById($activeId$metaInfo);
  120.         $root $this->getMetaInfoById($rootId$metaInfo);
  121.         // Validate the provided category is part of the sales channel
  122.         $this->validate($activeId$active['path'], $context);
  123.         $isChild $this->isChildCategory($activeId$active['path'], $rootId);
  124.         // If the provided activeId is not part of the rootId, a fallback to the rootId must be made here.
  125.         // The passed activeId is therefore part of another navigation and must therefore not be loaded.
  126.         // The availability validation has already been done in the `validate` function.
  127.         if (!$isChild) {
  128.             $activeId $rootId;
  129.         }
  130.         $categories = new CategoryCollection();
  131.         if ($depth 0) {
  132.             // Load the first two levels without using the activeId in the query
  133.             $categories $this->loadLevels($rootId, (int) $root['level'], $context, clone $criteria$depth);
  134.         }
  135.         // If the active category is part of the provided root id, we have to load the children and the parents of the active id
  136.         $categories $this->loadChildren($activeId$context$rootId$metaInfo$categories, clone $criteria);
  137.         return new NavigationRouteResponse($categories);
  138.     }
  139.     private function loadCategories(array $idsSalesChannelContext $contextCriteria $criteria): CategoryCollection
  140.     {
  141.         $criteria->setIds($ids);
  142.         $criteria->addAssociation('media');
  143.         $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
  144.         /** @var CategoryCollection $missing */
  145.         $missing $this->categoryRepository->search($criteria$context)->getEntities();
  146.         return $missing;
  147.     }
  148.     private function loadLevels(string $rootIdint $rootLevelSalesChannelContext $contextCriteria $criteriaint $depth 2): CategoryCollection
  149.     {
  150.         $criteria->addFilter(
  151.             new ContainsFilter('path''|' $rootId '|'),
  152.             new RangeFilter('level', [
  153.                 RangeFilter::GT => $rootLevel,
  154.                 RangeFilter::LTE => $rootLevel $depth 1,
  155.             ])
  156.         );
  157.         $criteria->addAssociation('media');
  158.         $criteria->setLimit(null);
  159.         $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
  160.         /** @var CategoryCollection $levels */
  161.         $levels $this->categoryRepository->search($criteria$context)->getEntities();
  162.         $this->addVisibilityCounts($rootId$rootLevel$depth$levels$context);
  163.         return $levels;
  164.     }
  165.     private function getCategoryMetaInfo(string $activeIdstring $rootId): array
  166.     {
  167.         $result $this->connection->fetchAllAssociative('
  168.             # navigation-route::meta-information
  169.             SELECT LOWER(HEX(`id`)), `path`, `level`
  170.             FROM `category`
  171.             WHERE `id` = :activeId OR `parent_id` = :activeId OR `id` = :rootId
  172.         ', ['activeId' => Uuid::fromHexToBytes($activeId), 'rootId' => Uuid::fromHexToBytes($rootId)]);
  173.         if (!$result) {
  174.             throw new CategoryNotFoundException($activeId);
  175.         }
  176.         return FetchModeHelper::groupUnique($result);
  177.     }
  178.     private function getMetaInfoById(string $id, array $metaInfo): array
  179.     {
  180.         if (!\array_key_exists($id$metaInfo)) {
  181.             throw new CategoryNotFoundException($id);
  182.         }
  183.         return $metaInfo[$id];
  184.     }
  185.     private function loadChildren(string $activeIdSalesChannelContext $contextstring $rootId, array $metaInfoCategoryCollection $categoriesCriteria $criteria): CategoryCollection
  186.     {
  187.         $active $this->getMetaInfoById($activeId$metaInfo);
  188.         unset($metaInfo[$rootId], $metaInfo[$activeId]);
  189.         $childIds array_keys($metaInfo);
  190.         // Fetch all parents and first-level children of the active category, if they're not already fetched
  191.         $missing $this->getMissingIds($activeId$active['path'], $childIds$categories);
  192.         if (empty($missing)) {
  193.             return $categories;
  194.         }
  195.         $categories->merge(
  196.             $this->loadCategories($missing$context$criteria)
  197.         );
  198.         return $categories;
  199.     }
  200.     private function getMissingIds(string $activeId, ?string $path, array $childIdsCategoryCollection $alreadyLoaded): array
  201.     {
  202.         $parentIds array_filter(explode('|'$path ?? ''));
  203.         $haveToBeIncluded array_merge($childIds$parentIds, [$activeId]);
  204.         $included $alreadyLoaded->getIds();
  205.         $included array_flip($included);
  206.         return array_diff($haveToBeIncluded$included);
  207.     }
  208.     private function validate(string $activeId, ?string $pathSalesChannelContext $context): void
  209.     {
  210.         $ids array_filter([
  211.             $context->getSalesChannel()->getFooterCategoryId(),
  212.             $context->getSalesChannel()->getServiceCategoryId(),
  213.             $context->getSalesChannel()->getNavigationCategoryId(),
  214.         ]);
  215.         foreach ($ids as $id) {
  216.             if ($this->isChildCategory($activeId$path$id)) {
  217.                 return;
  218.             }
  219.         }
  220.         throw new CategoryNotFoundException($activeId);
  221.     }
  222.     private function isChildCategory(string $activeId, ?string $pathstring $rootId): bool
  223.     {
  224.         if ($rootId === $activeId) {
  225.             return true;
  226.         }
  227.         if ($path === null) {
  228.             return false;
  229.         }
  230.         if (mb_strpos($path'|' $rootId '|') !== false) {
  231.             return true;
  232.         }
  233.         return false;
  234.     }
  235.     private function addVisibilityCounts(string $rootIdint $rootLevelint $depthCategoryCollection $levelsSalesChannelContext $context): void
  236.     {
  237.         $counts = [];
  238.         foreach ($levels as $category) {
  239.             if (!$category->getActive() || !$category->getVisible()) {
  240.                 continue;
  241.             }
  242.             $parentId $category->getParentId();
  243.             $counts[$parentId] = $counts[$parentId] ?? 0;
  244.             ++$counts[$parentId];
  245.         }
  246.         foreach ($levels as $category) {
  247.             $category->setVisibleChildCount($counts[$category->getId()] ?? 0);
  248.         }
  249.         // Fetch additional level of categories for counting visible children that are NOT included in the original query
  250.         $criteria = new Criteria();
  251.         $criteria->addFilter(
  252.             new ContainsFilter('path''|' $rootId '|'),
  253.             new EqualsFilter('level'$rootLevel $depth 1),
  254.             new EqualsFilter('active'true),
  255.             new EqualsFilter('visible'true)
  256.         );
  257.         $criteria->addAggregation(
  258.             new TermsAggregation('category-ids''parentId'nullnull, new CountAggregation('visible-children-count''id'))
  259.         );
  260.         $termsResult $this->categoryRepository
  261.             ->aggregate($criteria$context)
  262.             ->get('category-ids');
  263.         if (!($termsResult instanceof TermsResult)) {
  264.             return;
  265.         }
  266.         foreach ($termsResult->getBuckets() as $bucket) {
  267.             $key $bucket->getKey();
  268.             if ($key === null) {
  269.                 continue;
  270.             }
  271.             $parent $levels->get($key);
  272.             if ($parent instanceof CategoryEntity) {
  273.                 $parent->setVisibleChildCount($bucket->getCount());
  274.             }
  275.         }
  276.     }
  277. }