src/Core/Framework/Api/Controller/ApiController.php line 253

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