vendor/shopware/core/Framework/Api/Controller/ApiController.php line 420

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Framework\Api\Controller;
  3. use OpenApi\Annotations as OA;
  4. use Shopware\Core\Defaults;
  5. use Shopware\Core\Framework\Api\Acl\AclCriteriaValidator;
  6. use Shopware\Core\Framework\Api\Acl\Role\AclRoleDefinition;
  7. use Shopware\Core\Framework\Api\Converter\ApiVersionConverter;
  8. use Shopware\Core\Framework\Api\Converter\Exceptions\ApiConversionException;
  9. use Shopware\Core\Framework\Api\Exception\InvalidVersionNameException;
  10. use Shopware\Core\Framework\Api\Exception\LiveVersionDeleteException;
  11. use Shopware\Core\Framework\Api\Exception\MissingPrivilegeException;
  12. use Shopware\Core\Framework\Api\Exception\NoEntityClonedException;
  13. use Shopware\Core\Framework\Api\Exception\ResourceNotFoundException;
  14. use Shopware\Core\Framework\Api\Response\ResponseFactoryInterface;
  15. use Shopware\Core\Framework\Context;
  16. use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Entity;
  18. use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
  19. use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
  20. use Shopware\Core\Framework\DataAbstractionLayer\EntityProtection\EntityProtectionValidator;
  21. use Shopware\Core\Framework\DataAbstractionLayer\EntityProtection\ReadProtection;
  22. use Shopware\Core\Framework\DataAbstractionLayer\EntityProtection\WriteProtection;
  23. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  24. use Shopware\Core\Framework\DataAbstractionLayer\EntityTranslationDefinition;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Exception\DefinitionNotFoundException;
  27. use Shopware\Core\Framework\DataAbstractionLayer\Exception\MissingReverseAssociation;
  28. use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
  29. use Shopware\Core\Framework\DataAbstractionLayer\Field\Field;
  30. use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
  31. use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField;
  32. use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
  33. use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
  34. use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField;
  35. use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;
  36. use Shopware\Core\Framework\DataAbstractionLayer\MappingEntityDefinition;
  37. use Shopware\Core\Framework\DataAbstractionLayer\Search\CompositeEntitySearcher;
  38. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  39. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
  40. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  41. use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult;
  42. use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder;
  43. use Shopware\Core\Framework\DataAbstractionLayer\Write\CloneBehavior;
  44. use Shopware\Core\Framework\Feature;
  45. use Shopware\Core\Framework\Routing\Annotation\RouteScope;
  46. use Shopware\Core\Framework\Routing\Annotation\Since;
  47. use Shopware\Core\Framework\Routing\Exception\MissingRequestParameterException;
  48. use Shopware\Core\Framework\Uuid\Exception\InvalidUuidException;
  49. use Shopware\Core\Framework\Uuid\Uuid;
  50. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  51. use Symfony\Component\HttpFoundation\JsonResponse;
  52. use Symfony\Component\HttpFoundation\Request;
  53. use Symfony\Component\HttpFoundation\Response;
  54. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  55. use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
  56. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  57. use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
  58. use Symfony\Component\Routing\Annotation\Route;
  59. use Symfony\Component\Serializer\Exception\InvalidArgumentException;
  60. use Symfony\Component\Serializer\Exception\UnexpectedValueException;
  61. use Symfony\Component\Serializer\Serializer;
  62. /**
  63.  * @Route(defaults={"_routeScope"={"api"}})
  64.  */
  65. class ApiController extends AbstractController
  66. {
  67.     public const WRITE_UPDATE 'update';
  68.     public const WRITE_CREATE 'create';
  69.     public const WRITE_DELETE 'delete';
  70.     /**
  71.      * @var DefinitionInstanceRegistry
  72.      */
  73.     private $definitionRegistry;
  74.     /**
  75.      * @var Serializer
  76.      */
  77.     private $serializer;
  78.     /**
  79.      * @var RequestCriteriaBuilder
  80.      */
  81.     private $criteriaBuilder;
  82.     /**
  83.      * @var CompositeEntitySearcher
  84.      */
  85.     private $compositeEntitySearcher;
  86.     /**
  87.      * @var ApiVersionConverter
  88.      */
  89.     private $apiVersionConverter;
  90.     /**
  91.      * @var EntityProtectionValidator
  92.      */
  93.     private $entityProtectionValidator;
  94.     /**
  95.      * @var AclCriteriaValidator
  96.      */
  97.     private $criteriaValidator;
  98.     public function __construct(
  99.         DefinitionInstanceRegistry $definitionRegistry,
  100.         Serializer $serializer,
  101.         RequestCriteriaBuilder $criteriaBuilder,
  102.         CompositeEntitySearcher $compositeEntitySearcher,
  103.         ApiVersionConverter $apiVersionConverter,
  104.         EntityProtectionValidator $entityProtectionValidator,
  105.         AclCriteriaValidator $criteriaValidator
  106.     ) {
  107.         $this->definitionRegistry $definitionRegistry;
  108.         $this->serializer $serializer;
  109.         $this->criteriaBuilder $criteriaBuilder;
  110.         $this->compositeEntitySearcher $compositeEntitySearcher;
  111.         $this->apiVersionConverter $apiVersionConverter;
  112.         $this->entityProtectionValidator $entityProtectionValidator;
  113.         $this->criteriaValidator $criteriaValidator;
  114.     }
  115.     /**
  116.      * @Since("6.0.0.0")
  117.      * @OA\Get(
  118.      *      path="/_search",
  119.      *      summary="Search for multiple entites by a given term",
  120.      *      operationId="compositeSearch",
  121.      *      tags={"Admin Api"},
  122.      *      deprecated=true,
  123.      *      @OA\Parameter(
  124.      *          name="limit",
  125.      *          in="query",
  126.      *          description="Max amount of resources per entity",
  127.      *          @OA\Schema(type="integer"),
  128.      *      ),
  129.      *      @OA\Parameter(
  130.      *          name="term",
  131.      *          in="query",
  132.      *          description="The term to search for",
  133.      *          required=true,
  134.      *          @OA\Schema(type="string")
  135.      *      ),
  136.      *      @OA\Response(
  137.      *          response="200",
  138.      *          description="The list of found entities",
  139.      *          @OA\JsonContent(
  140.      *              type="array",
  141.      *              @OA\Items(
  142.      *                  type="object",
  143.      *                  @OA\Property(
  144.      *                      property="entity",
  145.      *                      type="string",
  146.      *                      description="The name of the entity",
  147.      *                  ),
  148.      *                  @OA\Property(
  149.      *                      property="total",
  150.      *                      type="integer",
  151.      *                      description="The total amount of search results for this entity",
  152.      *                  ),
  153.      *                  @OA\Property(
  154.      *                      property="entities",
  155.      *                      type="array",
  156.      *                      description="The found entities",
  157.      *                      @OA\Items(type="object", additionalProperties=true),
  158.      *                  ),
  159.      *              ),
  160.      *          ),
  161.      *      ),
  162.      *      @OA\Response(
  163.      *          response="400",
  164.      *          ref="#/components/responses/400"
  165.      *      ),
  166.      *     @OA\Response(
  167.      *          response="401",
  168.      *          ref="#/components/responses/401"
  169.      *      )
  170.      * )
  171.      * @Route("/api/_search", name="api.composite.search", methods={"GET","POST"})
  172.      *
  173.      * @deprecated tag:v6.5.0 - Will be removed in the next major
  174.      */
  175.     public function compositeSearch(Request $requestContext $context): JsonResponse
  176.     {
  177.         Feature::throwException('FEATURE_NEXT_18762''Will be removed in v6.5.0, use Shopware\Administration\Controller\AdminSearchController::search instead.');
  178.         $term = (string) $request->query->get('term');
  179.         if ($term === '') {
  180.             throw new MissingRequestParameterException('term');
  181.         }
  182.         $limit $request->query->getInt('limit'5);
  183.         $results $this->compositeEntitySearcher->search($term$limit$context);
  184.         foreach ($results as &$result) {
  185.             $definition $this->definitionRegistry->getByEntityName($result['entity']);
  186.             /** @var EntityCollection $entityCollection */
  187.             $entityCollection $result['entities'];
  188.             $entities = [];
  189.             foreach ($entityCollection->getElements() as $key => $entity) {
  190.                 $entities[$key] = $this->apiVersionConverter->convertEntity($definition$entity);
  191.             }
  192.             $result['entities'] = $entities;
  193.         }
  194.         return new JsonResponse(['data' => $results]);
  195.     }
  196.     /**
  197.      * @Since("6.0.0.0")
  198.      * @Route("/api/_action/clone/{entity}/{id}", name="api.clone", methods={"POST"}, requirements={
  199.      *     "version"="\d+", "entity"="[a-zA-Z-]+", "id"="[0-9a-f]{32}"
  200.      * })
  201.      */
  202.     public function clone(Context $contextstring $entitystring $idRequest $request): JsonResponse
  203.     {
  204.         $behavior = new CloneBehavior(
  205.             $request->request->all('overwrites'),
  206.             $request->request->getBoolean('cloneChildren'true)
  207.         );
  208.         $entity $this->urlToSnakeCase($entity);
  209.         $definition $this->definitionRegistry->getByEntityName($entity);
  210.         $missing $this->validateAclPermissions($context$definitionAclRoleDefinition::PRIVILEGE_CREATE);
  211.         if ($missing) {
  212.             throw new MissingPrivilegeException([$missing]);
  213.         }
  214.         $eventContainer $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($definition$id$behavior): EntityWrittenContainerEvent {
  215.             /** @var EntityRepository $entityRepo */
  216.             $entityRepo $this->definitionRegistry->getRepository($definition->getEntityName());
  217.             return $entityRepo->clone($id$contextnull$behavior);
  218.         });
  219.         $event $eventContainer->getEventByEntityName($definition->getEntityName());
  220.         if (!$event) {
  221.             throw new NoEntityClonedException($entity$id);
  222.         }
  223.         $ids $event->getIds();
  224.         $newId array_shift($ids);
  225.         return new JsonResponse(['id' => $newId]);
  226.     }
  227.     /**
  228.      * @Since("6.0.0.0")
  229.      * @Route("/api/_action/version/{entity}/{id}", name="api.createVersion", methods={"POST"},
  230.      *     requirements={"version"="\d+", "entity"="[a-zA-Z-]+", "id"="[0-9a-f]{32}"
  231.      * })
  232.      */
  233.     public function createVersion(Request $requestContext $contextstring $entitystring $id): Response
  234.     {
  235.         $entity $this->urlToSnakeCase($entity);
  236.         $versionId $request->request->has('versionId') ? (string) $request->request->get('versionId') : null;
  237.         $versionName $request->request->has('versionName') ? (string) $request->request->get('versionName') : null;
  238.         if ($versionId !== null && !Uuid::isValid($versionId)) {
  239.             throw new InvalidUuidException($versionId);
  240.         }
  241.         if ($versionName !== null && !ctype_alnum($versionName)) {
  242.             throw new InvalidVersionNameException();
  243.         }
  244.         try {
  245.             $entityDefinition $this->definitionRegistry->getByEntityName($entity);
  246.         } catch (DefinitionNotFoundException $e) {
  247.             throw new NotFoundHttpException($e->getMessage(), $e);
  248.         }
  249.         $versionId $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($entityDefinition$id$versionName$versionId): string {
  250.             return $this->definitionRegistry->getRepository($entityDefinition->getEntityName())->createVersion($id$context$versionName$versionId);
  251.         });
  252.         return new JsonResponse([
  253.             'versionId' => $versionId,
  254.             'versionName' => $versionName,
  255.             'id' => $id,
  256.             'entity' => $entity,
  257.         ]);
  258.     }
  259.     /**
  260.      * @Since("6.0.0.0")
  261.      * @Route("/api/_action/version/merge/{entity}/{versionId}", name="api.mergeVersion", methods={"POST"},
  262.      *     requirements={"version"="\d+", "entity"="[a-zA-Z-]+", "versionId"="[0-9a-f]{32}"
  263.      * })
  264.      */
  265.     public function mergeVersion(Context $contextstring $entitystring $versionId): JsonResponse
  266.     {
  267.         $entity $this->urlToSnakeCase($entity);
  268.         if (!Uuid::isValid($versionId)) {
  269.             throw new InvalidUuidException($versionId);
  270.         }
  271.         $entityDefinition $this->getEntityDefinition($entity);
  272.         $repository $this->definitionRegistry->getRepository($entityDefinition->getEntityName());
  273.         // change scope to be able to update write protected fields
  274.         $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($repository$versionId): void {
  275.             $repository->merge($versionId$context);
  276.         });
  277.         return new JsonResponse(nullResponse::HTTP_NO_CONTENT);
  278.     }
  279.     /**
  280.      * @Since("6.0.0.0")
  281.      * @Route("/api/_action/version/{versionId}/{entity}/{entityId}", name="api.deleteVersion", methods={"POST"},
  282.      *     requirements={"version"="\d+", "entity"="[a-zA-Z-]+", "id"="[0-9a-f]{32}"
  283.      * })
  284.      */
  285.     public function deleteVersion(Context $contextstring $entitystring $entityIdstring $versionId): JsonResponse
  286.     {
  287.         if ($versionId !== null && !Uuid::isValid($versionId)) {
  288.             throw new InvalidUuidException($versionId);
  289.         }
  290.         if ($versionId === Defaults::LIVE_VERSION) {
  291.             throw new LiveVersionDeleteException();
  292.         }
  293.         if ($entityId !== null && !Uuid::isValid($entityId)) {
  294.             throw new InvalidUuidException($entityId);
  295.         }
  296.         try {
  297.             $entityDefinition $this->definitionRegistry->getByEntityName($this->urlToSnakeCase($entity));
  298.         } catch (DefinitionNotFoundException $e) {
  299.             throw new NotFoundHttpException($e->getMessage(), $e);
  300.         }
  301.         $versionContext $context->createWithVersionId($versionId);
  302.         $entityRepository $this->definitionRegistry->getRepository($entityDefinition->getEntityName());
  303.         $versionContext->scope(Context::CRUD_API_SCOPE, function (Context $versionContext) use ($entityId$entityRepository): void {
  304.             $entityRepository->delete([['id' => $entityId]], $versionContext);
  305.         });
  306.         $versionRepository $this->definitionRegistry->getRepository('version');
  307.         $versionRepository->delete([['id' => $versionId]], $context);
  308.         return new JsonResponse();
  309.     }
  310.     public function detail(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  311.     {
  312.         $pathSegments $this->buildEntityPath($entityName$path$context);
  313.         $permissions $this->validatePathSegments($context$pathSegmentsAclRoleDefinition::PRIVILEGE_READ);
  314.         $root $pathSegments[0]['entity'];
  315.         $id $pathSegments[\count($pathSegments) - 1]['value'];
  316.         $definition $this->definitionRegistry->getByEntityName($root);
  317.         $associations array_column($pathSegments'entity');
  318.         array_shift($associations);
  319.         if (empty($associations)) {
  320.             $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  321.         } else {
  322.             $field $this->getAssociation($definition->getFields(), $associations);
  323.             $definition $field->getReferenceDefinition();
  324.             if ($field instanceof ManyToManyAssociationField) {
  325.                 $definition $field->getToManyReferenceDefinition();
  326.             }
  327.             $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  328.         }
  329.         $criteria = new Criteria();
  330.         $criteria $this->criteriaBuilder->handleRequest($request$criteria$definition$context);
  331.         $criteria->setIds([$id]);
  332.         // trigger acl validation
  333.         $missing $this->criteriaValidator->validate($definition->getEntityName(), $criteria$context);
  334.         $permissions array_unique(array_filter(array_merge($permissions$missing)));
  335.         if (!empty($permissions)) {
  336.             throw new MissingPrivilegeException($permissions);
  337.         }
  338.         $entity $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($repository$criteria$id): ?Entity {
  339.             return $repository->search($criteria$context)->get($id);
  340.         });
  341.         if ($entity === null) {
  342.             throw new ResourceNotFoundException($definition->getEntityName(), ['id' => $id]);
  343.         }
  344.         return $responseFactory->createDetailResponse($criteria$entity$definition$request$context);
  345.     }
  346.     public function searchIds(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  347.     {
  348.         [$criteria$repository] = $this->resolveSearch($request$context$entityName$path);
  349.         $result $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($repository$criteria): IdSearchResult {
  350.             return $repository->searchIds($criteria$context);
  351.         });
  352.         return new JsonResponse([
  353.             'total' => $result->getTotal(),
  354.             'data' => array_values($result->getIds()),
  355.         ]);
  356.     }
  357.     public function search(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  358.     {
  359.         [$criteria$repository] = $this->resolveSearch($request$context$entityName$path);
  360.         $result $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($repository$criteria): EntitySearchResult {
  361.             return $repository->search($criteria$context);
  362.         });
  363.         $definition $this->getDefinitionOfPath($entityName$path$context);
  364.         return $responseFactory->createListingResponse($criteria$result$definition$request$context);
  365.     }
  366.     public function list(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  367.     {
  368.         [$criteria$repository] = $this->resolveSearch($request$context$entityName$path);
  369.         $result $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($repository$criteria): EntitySearchResult {
  370.             return $repository->search($criteria$context);
  371.         });
  372.         $definition $this->getDefinitionOfPath($entityName$path$context);
  373.         return $responseFactory->createListingResponse($criteria$result$definition$request$context);
  374.     }
  375.     public function create(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  376.     {
  377.         return $this->write($request$context$responseFactory$entityName$pathself::WRITE_CREATE);
  378.     }
  379.     public function update(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  380.     {
  381.         return $this->write($request$context$responseFactory$entityName$pathself::WRITE_UPDATE);
  382.     }
  383.     public function delete(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $path): Response
  384.     {
  385.         $pathSegments $this->buildEntityPath($entityName$path$context, [WriteProtection::class]);
  386.         $last $pathSegments[\count($pathSegments) - 1];
  387.         $id $last['value'];
  388.         $first array_shift($pathSegments);
  389.         if (\count($pathSegments) === 0) {
  390.             //first api level call /product/{id}
  391.             $definition $first['definition'];
  392.             $this->executeWriteOperation($definition, ['id' => $id], $contextself::WRITE_DELETE);
  393.             return $responseFactory->createRedirectResponse($definition$id$request$context);
  394.         }
  395.         $child array_pop($pathSegments);
  396.         $parent $first;
  397.         if (!empty($pathSegments)) {
  398.             $parent array_pop($pathSegments);
  399.         }
  400.         $definition $child['definition'];
  401.         /** @var AssociationField $association */
  402.         $association $child['field'];
  403.         // DELETE api/product/{id}/manufacturer/{id}
  404.         if ($association instanceof ManyToOneAssociationField || $association instanceof OneToOneAssociationField) {
  405.             $this->executeWriteOperation($definition, ['id' => $id], $contextself::WRITE_DELETE);
  406.             return $responseFactory->createRedirectResponse($definition$id$request$context);
  407.         }
  408.         // DELETE api/product/{id}/category/{id}
  409.         if ($association instanceof ManyToManyAssociationField) {
  410.             $local $definition->getFields()->getByStorageName(
  411.                 $association->getMappingLocalColumn()
  412.             );
  413.             $reference $definition->getFields()->getByStorageName(
  414.                 $association->getMappingReferenceColumn()
  415.             );
  416.             $mapping = [
  417.                 $local->getPropertyName() => $parent['value'],
  418.                 $reference->getPropertyName() => $id,
  419.             ];
  420.             /** @var EntityDefinition $parentDefinition */
  421.             $parentDefinition $parent['definition'];
  422.             if ($parentDefinition->isVersionAware()) {
  423.                 $versionField $parentDefinition->getEntityName() . 'VersionId';
  424.                 $mapping[$versionField] = $context->getVersionId();
  425.             }
  426.             if ($association->getToManyReferenceDefinition()->isVersionAware()) {
  427.                 $versionField $association->getToManyReferenceDefinition()->getEntityName() . 'VersionId';
  428.                 $mapping[$versionField] = Defaults::LIVE_VERSION;
  429.             }
  430.             $this->executeWriteOperation($definition$mapping$contextself::WRITE_DELETE);
  431.             return $responseFactory->createRedirectResponse($definition$id$request$context);
  432.         }
  433.         if ($association instanceof TranslationsAssociationField) {
  434.             /** @var EntityTranslationDefinition $refClass */
  435.             $refClass $association->getReferenceDefinition();
  436.             $refPropName $refClass->getFields()->getByStorageName($association->getReferenceField())->getPropertyName();
  437.             $refLanguagePropName $refClass->getPrimaryKeys()->getByStorageName($association->getLanguageField())->getPropertyName();
  438.             $mapping = [
  439.                 $refPropName => $parent['value'],
  440.                 $refLanguagePropName => $id,
  441.             ];
  442.             $this->executeWriteOperation($definition$mapping$contextself::WRITE_DELETE);
  443.             return $responseFactory->createRedirectResponse($definition$id$request$context);
  444.         }
  445.         if ($association instanceof OneToManyAssociationField) {
  446.             $this->executeWriteOperation($definition, ['id' => $id], $contextself::WRITE_DELETE);
  447.             return $responseFactory->createRedirectResponse($definition$id$request$context);
  448.         }
  449.         throw new \RuntimeException(sprintf('Unsupported association for field %s'$association->getPropertyName()));
  450.     }
  451.     private function resolveSearch(Request $requestContext $contextstring $entityNamestring $path): array
  452.     {
  453.         $pathSegments $this->buildEntityPath($entityName$path$context);
  454.         $permissions $this->validatePathSegments($context$pathSegmentsAclRoleDefinition::PRIVILEGE_READ);
  455.         $first array_shift($pathSegments);
  456.         /** @var EntityDefinition|string $definition */
  457.         $definition $first['definition'];
  458.         if (!$definition) {
  459.             throw new NotFoundHttpException('The requested entity does not exist.');
  460.         }
  461.         $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  462.         $criteria = new Criteria();
  463.         if (empty($pathSegments)) {
  464.             $criteria $this->criteriaBuilder->handleRequest($request$criteria$definition$context);
  465.             // trigger acl validation
  466.             $nested $this->criteriaValidator->validate($definition->getEntityName(), $criteria$context);
  467.             $permissions array_unique(array_filter(array_merge($permissions$nested)));
  468.             if (!empty($permissions)) {
  469.                 throw new MissingPrivilegeException($permissions);
  470.             }
  471.             return [$criteria$repository];
  472.         }
  473.         $child array_pop($pathSegments);
  474.         $parent $first;
  475.         if (!empty($pathSegments)) {
  476.             $parent array_pop($pathSegments);
  477.         }
  478.         $association $child['field'];
  479.         $parentDefinition $parent['definition'];
  480.         $definition $child['definition'];
  481.         if ($association instanceof ManyToManyAssociationField) {
  482.             $definition $association->getToManyReferenceDefinition();
  483.         }
  484.         $criteria $this->criteriaBuilder->handleRequest($request$criteria$definition$context);
  485.         if ($association instanceof ManyToManyAssociationField) {
  486.             //fetch inverse association definition for filter
  487.             $reverse $definition->getFields()->filter(
  488.                 function (Field $field) use ($association) {
  489.                     return $field instanceof ManyToManyAssociationField && $association->getMappingDefinition() === $field->getMappingDefinition();
  490.                 }
  491.             );
  492.             //contains now the inverse side association: category.products
  493.             $reverse $reverse->first();
  494.             if (!$reverse) {
  495.                 throw new MissingReverseAssociation($definition->getEntityName(), $parentDefinition);
  496.             }
  497.             $criteria->addFilter(
  498.                 new EqualsFilter(
  499.                     sprintf('%s.%s.id'$definition->getEntityName(), $reverse->getPropertyName()),
  500.                     $parent['value']
  501.                 )
  502.             );
  503.             /** @var EntityDefinition $parentDefinition */
  504.             if ($parentDefinition->isVersionAware()) {
  505.                 $criteria->addFilter(
  506.                     new EqualsFilter(
  507.                         sprintf('%s.%s.versionId'$definition->getEntityName(), $reverse->getPropertyName()),
  508.                         $context->getVersionId()
  509.                     )
  510.                 );
  511.             }
  512.         } elseif ($association instanceof OneToManyAssociationField) {
  513.             /*
  514.              * Example
  515.              * Route:           /api/product/SW1/prices
  516.              * $definition:     \Shopware\Core\Content\Product\Definition\ProductPriceDefinition
  517.              */
  518.             //get foreign key definition of reference
  519.             $foreignKey $definition->getFields()->getByStorageName(
  520.                 $association->getReferenceField()
  521.             );
  522.             $criteria->addFilter(
  523.                 new EqualsFilter(
  524.                 //add filter to parent value: prices.productId = SW1
  525.                     $definition->getEntityName() . '.' $foreignKey->getPropertyName(),
  526.                     $parent['value']
  527.                 )
  528.             );
  529.         } elseif ($association instanceof ManyToOneAssociationField) {
  530.             /*
  531.              * Example
  532.              * Route:           /api/product/SW1/manufacturer
  533.              * $definition:     \Shopware\Core\Content\Product\Aggregate\ProductManufacturer\ProductManufacturerDefinition
  534.              */
  535.             //get inverse association to filter to parent value
  536.             $reverse $definition->getFields()->filter(
  537.                 function (Field $field) use ($parentDefinition) {
  538.                     return $field instanceof AssociationField && $parentDefinition === $field->getReferenceDefinition();
  539.                 }
  540.             );
  541.             $reverse $reverse->first();
  542.             if (!$reverse) {
  543.                 throw new MissingReverseAssociation($definition->getEntityName(), $parentDefinition);
  544.             }
  545.             $criteria->addFilter(
  546.                 new EqualsFilter(
  547.                 //filter inverse association to parent value:  manufacturer.products.id = SW1
  548.                     sprintf('%s.%s.id'$definition->getEntityName(), $reverse->getPropertyName()),
  549.                     $parent['value']
  550.                 )
  551.             );
  552.         } elseif ($association instanceof OneToOneAssociationField) {
  553.             /*
  554.              * Example
  555.              * Route:           /api/order/xxxx/orderCustomer
  556.              * $definition:     \Shopware\Core\Checkout\Order\Aggregate\OrderCustomer\OrderCustomerDefinition
  557.              */
  558.             //get inverse association to filter to parent value
  559.             $reverse $definition->getFields()->filter(
  560.                 function (Field $field) use ($parentDefinition) {
  561.                     return $field instanceof OneToOneAssociationField && $parentDefinition === $field->getReferenceDefinition();
  562.                 }
  563.             );
  564.             $reverse $reverse->first();
  565.             if (!$reverse) {
  566.                 throw new MissingReverseAssociation($definition->getEntityName(), $parentDefinition);
  567.             }
  568.             $criteria->addFilter(
  569.                 new EqualsFilter(
  570.                 //filter inverse association to parent value:  order_customer.order_id = xxxx
  571.                     sprintf('%s.%s.id'$definition->getEntityName(), $reverse->getPropertyName()),
  572.                     $parent['value']
  573.                 )
  574.             );
  575.         }
  576.         $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  577.         $nested $this->criteriaValidator->validate($definition->getEntityName(), $criteria$context);
  578.         $permissions array_unique(array_filter(array_merge($permissions$nested)));
  579.         if (!empty($permissions)) {
  580.             throw new MissingPrivilegeException($permissions);
  581.         }
  582.         return [$criteria$repository];
  583.     }
  584.     private function getDefinitionOfPath(string $entityNamestring $pathContext $context): EntityDefinition
  585.     {
  586.         $pathSegments $this->buildEntityPath($entityName$path$context);
  587.         $first array_shift($pathSegments);
  588.         /** @var EntityDefinition|string $definition */
  589.         $definition $first['definition'];
  590.         if (empty($pathSegments)) {
  591.             return $definition;
  592.         }
  593.         $child array_pop($pathSegments);
  594.         $association $child['field'];
  595.         if ($association instanceof ManyToManyAssociationField) {
  596.             /*
  597.              * Example:
  598.              * route:           /api/product/SW1/categories
  599.              * $definition:     \Shopware\Core\Content\Category\CategoryDefinition
  600.              */
  601.             return $association->getToManyReferenceDefinition();
  602.         }
  603.         return $child['definition'];
  604.     }
  605.     private function write(Request $requestContext $contextResponseFactoryInterface $responseFactorystring $entityNamestring $pathstring $type): Response
  606.     {
  607.         $payload $this->getRequestBody($request);
  608.         $noContent = !$request->query->has('_response');
  609.         // safari bug prevents us from using the location header
  610.         $appendLocationHeader false;
  611.         if ($this->isCollection($payload)) {
  612.             throw new BadRequestHttpException('Only single write operations are supported. Please send the entities one by one or use the /sync api endpoint.');
  613.         }
  614.         $pathSegments $this->buildEntityPath($entityName$path$context, [WriteProtection::class]);
  615.         $last $pathSegments[\count($pathSegments) - 1];
  616.         if ($type === self::WRITE_CREATE && !empty($last['value'])) {
  617.             $methods = ['GET''PATCH''DELETE'];
  618.             throw new MethodNotAllowedHttpException($methodssprintf('No route found for "%s %s": Method Not Allowed (Allow: %s)'$request->getMethod(), $request->getPathInfo(), implode(', '$methods)));
  619.         }
  620.         if ($type === self::WRITE_UPDATE && isset($last['value'])) {
  621.             $payload['id'] = $last['value'];
  622.         }
  623.         $first array_shift($pathSegments);
  624.         if (\count($pathSegments) === 0) {
  625.             $definition $first['definition'];
  626.             $events $this->executeWriteOperation($definition$payload$context$type);
  627.             $event $events->getEventByEntityName($definition->getEntityName());
  628.             $eventIds $event->getIds();
  629.             $entityId array_pop($eventIds);
  630.             if ($definition instanceof MappingEntityDefinition) {
  631.                 return new Response(nullResponse::HTTP_NO_CONTENT);
  632.             }
  633.             if ($noContent) {
  634.                 return $responseFactory->createRedirectResponse($definition$entityId$request$context);
  635.             }
  636.             $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  637.             $criteria = new Criteria($event->getIds());
  638.             $entities $repository->search($criteria$context);
  639.             return $responseFactory->createDetailResponse($criteria$entities->first(), $definition$request$context$appendLocationHeader);
  640.         }
  641.         $child array_pop($pathSegments);
  642.         $parent $first;
  643.         if (!empty($pathSegments)) {
  644.             $parent array_pop($pathSegments);
  645.         }
  646.         /** @var EntityDefinition $definition */
  647.         $definition $child['definition'];
  648.         $association $child['field'];
  649.         $parentDefinition $parent['definition'];
  650.         if ($association instanceof OneToManyAssociationField) {
  651.             $foreignKey $definition->getFields()
  652.                 ->getByStorageName($association->getReferenceField());
  653.             $payload[$foreignKey->getPropertyName()] = $parent['value'];
  654.             $events $this->executeWriteOperation($definition$payload$context$type);
  655.             if ($noContent) {
  656.                 return $responseFactory->createRedirectResponse($definition$parent['value'], $request$context);
  657.             }
  658.             $event $events->getEventByEntityName($definition->getEntityName());
  659.             $repository $this->definitionRegistry->getRepository($definition->getEntityName());
  660.             $criteria = new Criteria($event->getIds());
  661.             $entities $repository->search($criteria$context);
  662.             return $responseFactory->createDetailResponse($criteria$entities->first(), $definition$request$context$appendLocationHeader);
  663.         }
  664.         if ($association instanceof ManyToOneAssociationField || $association instanceof OneToOneAssociationField) {
  665.             $events $this->executeWriteOperation($definition$payload$context$type);
  666.             $event $events->getEventByEntityName($definition->getEntityName());
  667.             $entityIds $event->getIds();
  668.             $entityId array_pop($entityIds);
  669.             $foreignKey $parentDefinition->getFields()->getByStorageName($association->getStorageName());
  670.             $payload = [
  671.                 'id' => $parent['value'],
  672.                 $foreignKey->getPropertyName() => $entityId,
  673.             ];
  674.             $repository $this->definitionRegistry->getRepository($parentDefinition->getEntityName());
  675.             $repository->update([$payload], $context);
  676.             if ($noContent) {
  677.                 return $responseFactory->createRedirectResponse($definition$entityId$request$context);
  678.             }
  679.             $criteria = new Criteria($event->getIds());
  680.             $entities $repository->search($criteria$context);
  681.             return $responseFactory->createDetailResponse($criteria$entities->first(), $definition$request$context$appendLocationHeader);
  682.         }
  683.         /** @var ManyToManyAssociationField $manyToManyAssociation */
  684.         $manyToManyAssociation $association;
  685.         /** @var EntityDefinition|string $reference */
  686.         $reference $manyToManyAssociation->getToManyReferenceDefinition();
  687.         // check if we need to create the entity first
  688.         if (\count($payload) > || !\array_key_exists('id'$payload)) {
  689.             $events $this->executeWriteOperation($reference$payload$context$type);
  690.             $event $events->getEventByEntityName($reference->getEntityName());
  691.             $ids $event->getIds();
  692.             $id array_shift($ids);
  693.         } else {
  694.             // only id provided - add assignment
  695.             $id $payload['id'];
  696.         }
  697.         $payload = [
  698.             'id' => $parent['value'],
  699.             $manyToManyAssociation->getPropertyName() => [
  700.                 ['id' => $id],
  701.             ],
  702.         ];
  703.         $repository $this->definitionRegistry->getRepository($parentDefinition->getEntityName());
  704.         $repository->update([$payload], $context);
  705.         $repository $this->definitionRegistry->getRepository($reference->getEntityName());
  706.         $criteria = new Criteria([$id]);
  707.         $entities $repository->search($criteria$context);
  708.         $entity $entities->first();
  709.         if ($noContent) {
  710.             return $responseFactory->createRedirectResponse($reference$entity->getId(), $request$context);
  711.         }
  712.         return $responseFactory->createDetailResponse($criteria$entity$definition$request$context$appendLocationHeader);
  713.     }
  714.     private function executeWriteOperation(
  715.         EntityDefinition $entity,
  716.         array $payload,
  717.         Context $context,
  718.         string $type
  719.     ): EntityWrittenContainerEvent {
  720.         $repository $this->definitionRegistry->getRepository($entity->getEntityName());
  721.         $conversionException = new ApiConversionException();
  722.         $payload $this->apiVersionConverter->convertPayload($entity$payload$conversionException);
  723.         $conversionException->tryToThrow();
  724.         $event $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($repository$payload$entity$type): ?EntityWrittenContainerEvent {
  725.             if ($type === self::WRITE_CREATE) {
  726.                 return $repository->create([$payload], $context);
  727.             }
  728.             if ($type === self::WRITE_UPDATE) {
  729.                 return $repository->update([$payload], $context);
  730.             }
  731.             if ($type === self::WRITE_DELETE) {
  732.                 $event $repository->delete([$payload], $context);
  733.                 if (!empty($event->getErrors())) {
  734.                     throw new ResourceNotFoundException($entity->getEntityName(), $payload);
  735.                 }
  736.                 return $event;
  737.             }
  738.             return null;
  739.         });
  740.         if (!$event) {
  741.             throw new \RuntimeException('Unsupported write operation.');
  742.         }
  743.         return $event;
  744.     }
  745.     private function getAssociation(FieldCollection $fields, array $keys): AssociationField
  746.     {
  747.         $key array_shift($keys);
  748.         /** @var AssociationField $field */
  749.         $field $fields->get($key);
  750.         if (empty($keys)) {
  751.             return $field;
  752.         }
  753.         $reference $field->getReferenceDefinition();
  754.         $nested $reference->getFields();
  755.         return $this->getAssociation($nested$keys);
  756.     }
  757.     private function buildEntityPath(
  758.         string $entityName,
  759.         string $pathInfo,
  760.         Context $context,
  761.         array $protections = [ReadProtection::class]
  762.     ): array {
  763.         $pathInfo str_replace('/extensions/''/'$pathInfo);
  764.         $exploded explode('/'$entityName '/' ltrim($pathInfo'/'));
  765.         $parts = [];
  766.         foreach ($exploded as $index => $part) {
  767.             if ($index 2) {
  768.                 continue;
  769.             }
  770.             if (empty($part)) {
  771.                 continue;
  772.             }
  773.             $value $exploded[$index 1] ?? null;
  774.             if (empty($parts)) {
  775.                 $part $this->urlToSnakeCase($part);
  776.             } else {
  777.                 $part $this->urlToCamelCase($part);
  778.             }
  779.             $parts[] = [
  780.                 'entity' => $part,
  781.                 'value' => $value,
  782.             ];
  783.         }
  784.         $parts array_filter($parts);
  785.         /** @var array{'entity': string, 'value': string|null} $first */
  786.         $first array_shift($parts);
  787.         try {
  788.             $root $this->definitionRegistry->getByEntityName($first['entity']);
  789.         } catch (DefinitionNotFoundException $e) {
  790.             throw new NotFoundHttpException($e->getMessage(), $e);
  791.         }
  792.         $entities = [
  793.             [
  794.                 'entity' => $first['entity'],
  795.                 'value' => $first['value'],
  796.                 'definition' => $root,
  797.                 'field' => null,
  798.             ],
  799.         ];
  800.         foreach ($parts as $part) {
  801.             /** @var AssociationField|null $field */
  802.             $field $root->getFields()->get($part['entity']);
  803.             if (!$field) {
  804.                 $path implode('.'array_column($entities'entity')) . '.' $part['entity'];
  805.                 throw new NotFoundHttpException(sprintf('Resource at path "%s" is not an existing relation.'$path));
  806.             }
  807.             if ($field instanceof ManyToManyAssociationField) {
  808.                 $root $field->getToManyReferenceDefinition();
  809.             } else {
  810.                 $root $field->getReferenceDefinition();
  811.             }
  812.             $entities[] = [
  813.                 'entity' => $part['entity'],
  814.                 'value' => $part['value'],
  815.                 'definition' => $field->getReferenceDefinition(),
  816.                 'field' => $field,
  817.             ];
  818.         }
  819.         $context->scope(Context::CRUD_API_SCOPE, function (Context $context) use ($entities$protections): void {
  820.             $this->entityProtectionValidator->validateEntityPath($entities$protections$context);
  821.         });
  822.         return $entities;
  823.     }
  824.     private function urlToSnakeCase(string $name): string
  825.     {
  826.         return str_replace('-''_'$name);
  827.     }
  828.     private function urlToCamelCase(string $name): string
  829.     {
  830.         $parts explode('-'$name);
  831.         $parts array_map('ucfirst'$parts);
  832.         return lcfirst(implode(''$parts));
  833.     }
  834.     /**
  835.      * Return a nested array structure of based on the content-type
  836.      */
  837.     private function getRequestBody(Request $request): array
  838.     {
  839.         $contentType $request->headers->get('CONTENT_TYPE''');
  840.         $semicolonPosition mb_strpos($contentType';');
  841.         if ($semicolonPosition !== false) {
  842.             $contentType mb_substr($contentType0$semicolonPosition);
  843.         }
  844.         try {
  845.             switch ($contentType) {
  846.                 case 'application/vnd.api+json':
  847.                     return $this->serializer->decode($request->getContent(), 'jsonapi');
  848.                 case 'application/json':
  849.                     return $request->request->all();
  850.             }
  851.         } catch (InvalidArgumentException UnexpectedValueException $exception) {
  852.             throw new BadRequestHttpException($exception->getMessage());
  853.         }
  854.         throw new UnsupportedMediaTypeHttpException(sprintf('The Content-Type "%s" is unsupported.'$contentType));
  855.     }
  856.     private function isCollection(array $array): bool
  857.     {
  858.         return array_keys($array) === range(0, \count($array) - 1);
  859.     }
  860.     private function getEntityDefinition(string $entityName): EntityDefinition
  861.     {
  862.         try {
  863.             $entityDefinition $this->definitionRegistry->getByEntityName($entityName);
  864.         } catch (DefinitionNotFoundException $e) {
  865.             throw new NotFoundHttpException($e->getMessage(), $e);
  866.         }
  867.         return $entityDefinition;
  868.     }
  869.     private function validateAclPermissions(Context $contextEntityDefinition $entitystring $privilege): ?string
  870.     {
  871.         $resource $entity->getEntityName();
  872.         if ($entity instanceof EntityTranslationDefinition) {
  873.             $resource $entity->getParentDefinition()->getEntityName();
  874.         }
  875.         if (!$context->isAllowed($resource ':' $privilege)) {
  876.             return $resource ':' $privilege;
  877.         }
  878.         return null;
  879.     }
  880.     private function validatePathSegments(Context $context, array $pathSegmentsstring $privilege): array
  881.     {
  882.         $child array_pop($pathSegments);
  883.         $missing = [];
  884.         foreach ($pathSegments as $segment) {
  885.             // you need detail privileges for every parent entity
  886.             $missing[] = $this->validateAclPermissions(
  887.                 $context,
  888.                 $this->getDefinitionForPathSegment($segment),
  889.                 AclRoleDefinition::PRIVILEGE_READ
  890.             );
  891.         }
  892.         $missing[] = $this->validateAclPermissions($context$this->getDefinitionForPathSegment($child), $privilege);
  893.         return array_unique(array_filter($missing));
  894.     }
  895.     private function getDefinitionForPathSegment(array $segment): EntityDefinition
  896.     {
  897.         $definition $segment['definition'];
  898.         if ($segment['field'] instanceof ManyToManyAssociationField) {
  899.             $definition $segment['field']->getToManyReferenceDefinition();
  900.         }
  901.         return $definition;
  902.     }
  903. }