Total Complexity | 111 |
Total Lines | 957 |
Duplicated Lines | 0 % |
Changes | 0 |
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); |
||
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 |
||
|
|||
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 |
||
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 |
||
995 | } |
||
996 | |||
997 | /** |
||
998 | * @param EntityPathSegment $segment |
||
999 | */ |
||
1000 | private function getDefinitionForPathSegment(array $segment): EntityDefinition |
||
1009 | } |
||
1010 | } |
||
1011 |