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 |
|
|
|
|
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
|
|
|
|