ApiController   F
last analyzed

Complexity

Total Complexity 111

Size/Duplication

Total Lines 957
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 442
dl 0
loc 957
rs 2
c 0
b 0
f 0
wmc 111

26 Methods

Rating   Name   Duplication   Size   Complexity  
A getEntityDefinition() 0 9 2
A getAssociation() 0 15 2
A validatePathSegments() 0 19 2
A getRequestBody() 0 21 5
B buildEntityPath() 0 79 9
A getDefinitionForPathSegment() 0 9 2
A urlToCamelCase() 0 6 1
A isCollection() 0 3 1
A validateAclPermissions() 0 13 3
A urlToSnakeCase() 0 3 1
A __construct() 0 7 1
B delete() 0 103 10
A clone() 0 32 3
A list() 0 9 1
A searchIds() 0 9 1
C resolveSearch() 0 155 17
A mergeVersion() 0 18 2
A update() 0 3 1
A executeWriteOperation() 0 35 6
A deleteVersion() 0 33 5
A create() 0 3 1
A getDefinitionOfPath() 0 27 3
D write() 0 167 18
A detail() 0 48 5
A search() 0 9 1
B createVersion() 0 29 8

How to fix   Complexity   

Complex Class

Complex classes like ApiController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ApiController, and based on these observations, apply Extract Interface, too.

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