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

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