Chris@18
|
1 <?php
|
Chris@18
|
2
|
Chris@18
|
3 namespace Drupal\jsonapi\Controller;
|
Chris@18
|
4
|
Chris@18
|
5 use Drupal\Component\Assertion\Inspector;
|
Chris@18
|
6 use Drupal\Component\Datetime\TimeInterface;
|
Chris@18
|
7 use Drupal\Component\Serialization\Json;
|
Chris@18
|
8 use Drupal\Core\Cache\CacheableMetadata;
|
Chris@18
|
9 use Drupal\Core\Config\Entity\ConfigEntityInterface;
|
Chris@18
|
10 use Drupal\Core\Entity\ContentEntityInterface;
|
Chris@18
|
11 use Drupal\Core\Entity\EntityFieldManagerInterface;
|
Chris@18
|
12 use Drupal\Core\Entity\EntityInterface;
|
Chris@18
|
13 use Drupal\Core\Entity\EntityRepositoryInterface;
|
Chris@18
|
14 use Drupal\Core\Entity\EntityStorageInterface;
|
Chris@18
|
15 use Drupal\Core\Entity\EntityTypeManagerInterface;
|
Chris@18
|
16 use Drupal\Core\Entity\FieldableEntityInterface;
|
Chris@18
|
17 use Drupal\Core\Entity\Query\QueryInterface;
|
Chris@18
|
18 use Drupal\Core\Entity\RevisionableEntityBundleInterface;
|
Chris@18
|
19 use Drupal\Core\Entity\RevisionableInterface;
|
Chris@18
|
20 use Drupal\Core\Entity\RevisionableStorageInterface;
|
Chris@18
|
21 use Drupal\Core\Entity\RevisionLogInterface;
|
Chris@18
|
22 use Drupal\Core\Field\FieldDefinitionInterface;
|
Chris@18
|
23 use Drupal\Core\Field\FieldItemListInterface;
|
Chris@18
|
24 use Drupal\Core\Render\RenderContext;
|
Chris@18
|
25 use Drupal\Core\Render\RendererInterface;
|
Chris@18
|
26 use Drupal\Core\Session\AccountInterface;
|
Chris@18
|
27 use Drupal\Core\Url;
|
Chris@18
|
28 use Drupal\jsonapi\Access\EntityAccessChecker;
|
Chris@18
|
29 use Drupal\jsonapi\Context\FieldResolver;
|
Chris@18
|
30 use Drupal\jsonapi\Entity\EntityValidationTrait;
|
Chris@18
|
31 use Drupal\jsonapi\Access\TemporaryQueryGuard;
|
Chris@18
|
32 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
Chris@18
|
33 use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
|
Chris@18
|
34 use Drupal\jsonapi\IncludeResolver;
|
Chris@18
|
35 use Drupal\jsonapi\JsonApiResource\IncludedData;
|
Chris@18
|
36 use Drupal\jsonapi\JsonApiResource\LinkCollection;
|
Chris@18
|
37 use Drupal\jsonapi\JsonApiResource\NullIncludedData;
|
Chris@18
|
38 use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
|
Chris@18
|
39 use Drupal\jsonapi\JsonApiResource\Link;
|
Chris@18
|
40 use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
Chris@18
|
41 use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
|
Chris@18
|
42 use Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer;
|
Chris@18
|
43 use Drupal\jsonapi\Query\Filter;
|
Chris@18
|
44 use Drupal\jsonapi\Query\Sort;
|
Chris@18
|
45 use Drupal\jsonapi\Query\OffsetPage;
|
Chris@18
|
46 use Drupal\jsonapi\JsonApiResource\Data;
|
Chris@18
|
47 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
|
Chris@18
|
48 use Drupal\jsonapi\ResourceResponse;
|
Chris@18
|
49 use Drupal\jsonapi\ResourceType\ResourceType;
|
Chris@18
|
50 use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
Chris@18
|
51 use Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer;
|
Chris@18
|
52 use Symfony\Component\HttpFoundation\Request;
|
Chris@18
|
53 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
Chris@18
|
54 use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
Chris@18
|
55 use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
Chris@18
|
56 use Symfony\Component\Serializer\Exception\InvalidArgumentException;
|
Chris@18
|
57 use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
Chris@18
|
58 use Symfony\Component\Serializer\SerializerInterface;
|
Chris@18
|
59
|
Chris@18
|
60 /**
|
Chris@18
|
61 * Process all entity requests.
|
Chris@18
|
62 *
|
Chris@18
|
63 * @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
Chris@18
|
64 * may change at any time and could break any dependencies on it.
|
Chris@18
|
65 *
|
Chris@18
|
66 * @see https://www.drupal.org/project/jsonapi/issues/3032787
|
Chris@18
|
67 * @see jsonapi.api.php
|
Chris@18
|
68 */
|
Chris@18
|
69 class EntityResource {
|
Chris@18
|
70
|
Chris@18
|
71 use EntityValidationTrait;
|
Chris@18
|
72
|
Chris@18
|
73 /**
|
Chris@18
|
74 * The entity type manager.
|
Chris@18
|
75 *
|
Chris@18
|
76 * @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
Chris@18
|
77 */
|
Chris@18
|
78 protected $entityTypeManager;
|
Chris@18
|
79
|
Chris@18
|
80 /**
|
Chris@18
|
81 * The field manager.
|
Chris@18
|
82 *
|
Chris@18
|
83 * @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
Chris@18
|
84 */
|
Chris@18
|
85 protected $fieldManager;
|
Chris@18
|
86
|
Chris@18
|
87 /**
|
Chris@18
|
88 * The resource type repository.
|
Chris@18
|
89 *
|
Chris@18
|
90 * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
Chris@18
|
91 */
|
Chris@18
|
92 protected $resourceTypeRepository;
|
Chris@18
|
93
|
Chris@18
|
94 /**
|
Chris@18
|
95 * The renderer.
|
Chris@18
|
96 *
|
Chris@18
|
97 * @var \Drupal\Core\Render\RendererInterface
|
Chris@18
|
98 */
|
Chris@18
|
99 protected $renderer;
|
Chris@18
|
100
|
Chris@18
|
101 /**
|
Chris@18
|
102 * The entity repository.
|
Chris@18
|
103 *
|
Chris@18
|
104 * @var \Drupal\Core\Entity\EntityRepositoryInterface
|
Chris@18
|
105 */
|
Chris@18
|
106 protected $entityRepository;
|
Chris@18
|
107
|
Chris@18
|
108 /**
|
Chris@18
|
109 * The include resolver.
|
Chris@18
|
110 *
|
Chris@18
|
111 * @var \Drupal\jsonapi\IncludeResolver
|
Chris@18
|
112 */
|
Chris@18
|
113 protected $includeResolver;
|
Chris@18
|
114
|
Chris@18
|
115 /**
|
Chris@18
|
116 * The JSON:API entity access checker.
|
Chris@18
|
117 *
|
Chris@18
|
118 * @var \Drupal\jsonapi\Access\EntityAccessChecker
|
Chris@18
|
119 */
|
Chris@18
|
120 protected $entityAccessChecker;
|
Chris@18
|
121
|
Chris@18
|
122 /**
|
Chris@18
|
123 * The JSON:API field resolver.
|
Chris@18
|
124 *
|
Chris@18
|
125 * @var \Drupal\jsonapi\Context\FieldResolver
|
Chris@18
|
126 */
|
Chris@18
|
127 protected $fieldResolver;
|
Chris@18
|
128
|
Chris@18
|
129 /**
|
Chris@18
|
130 * The JSON:API serializer.
|
Chris@18
|
131 *
|
Chris@18
|
132 * @var \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface
|
Chris@18
|
133 */
|
Chris@18
|
134 protected $serializer;
|
Chris@18
|
135
|
Chris@18
|
136 /**
|
Chris@18
|
137 * The time service.
|
Chris@18
|
138 *
|
Chris@18
|
139 * @var \Drupal\Component\Datetime\TimeInterface
|
Chris@18
|
140 */
|
Chris@18
|
141 protected $time;
|
Chris@18
|
142
|
Chris@18
|
143 /**
|
Chris@18
|
144 * The current user account.
|
Chris@18
|
145 *
|
Chris@18
|
146 * @var \Drupal\Core\Session\AccountInterface
|
Chris@18
|
147 */
|
Chris@18
|
148 protected $user;
|
Chris@18
|
149
|
Chris@18
|
150 /**
|
Chris@18
|
151 * Instantiates a EntityResource object.
|
Chris@18
|
152 *
|
Chris@18
|
153 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
Chris@18
|
154 * The entity type manager.
|
Chris@18
|
155 * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
Chris@18
|
156 * The entity type field manager.
|
Chris@18
|
157 * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
Chris@18
|
158 * The JSON:API resource type repository.
|
Chris@18
|
159 * @param \Drupal\Core\Render\RendererInterface $renderer
|
Chris@18
|
160 * The renderer.
|
Chris@18
|
161 * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
|
Chris@18
|
162 * The entity repository.
|
Chris@18
|
163 * @param \Drupal\jsonapi\IncludeResolver $include_resolver
|
Chris@18
|
164 * The include resolver.
|
Chris@18
|
165 * @param \Drupal\jsonapi\Access\EntityAccessChecker $entity_access_checker
|
Chris@18
|
166 * The JSON:API entity access checker.
|
Chris@18
|
167 * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
|
Chris@18
|
168 * The JSON:API field resolver.
|
Chris@18
|
169 * @param \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface $serializer
|
Chris@18
|
170 * The JSON:API serializer.
|
Chris@18
|
171 * @param \Drupal\Component\Datetime\TimeInterface $time
|
Chris@18
|
172 * The time service.
|
Chris@18
|
173 * @param \Drupal\Core\Session\AccountInterface $user
|
Chris@18
|
174 * The current user account.
|
Chris@18
|
175 */
|
Chris@18
|
176 public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, ResourceTypeRepositoryInterface $resource_type_repository, RendererInterface $renderer, EntityRepositoryInterface $entity_repository, IncludeResolver $include_resolver, EntityAccessChecker $entity_access_checker, FieldResolver $field_resolver, SerializerInterface $serializer, TimeInterface $time, AccountInterface $user) {
|
Chris@18
|
177 $this->entityTypeManager = $entity_type_manager;
|
Chris@18
|
178 $this->fieldManager = $field_manager;
|
Chris@18
|
179 $this->resourceTypeRepository = $resource_type_repository;
|
Chris@18
|
180 $this->renderer = $renderer;
|
Chris@18
|
181 $this->entityRepository = $entity_repository;
|
Chris@18
|
182 $this->includeResolver = $include_resolver;
|
Chris@18
|
183 $this->entityAccessChecker = $entity_access_checker;
|
Chris@18
|
184 $this->fieldResolver = $field_resolver;
|
Chris@18
|
185 $this->serializer = $serializer;
|
Chris@18
|
186 $this->time = $time;
|
Chris@18
|
187 $this->user = $user;
|
Chris@18
|
188 }
|
Chris@18
|
189
|
Chris@18
|
190 /**
|
Chris@18
|
191 * Gets the individual entity.
|
Chris@18
|
192 *
|
Chris@18
|
193 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
194 * The loaded entity.
|
Chris@18
|
195 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
196 * The request object.
|
Chris@18
|
197 *
|
Chris@18
|
198 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
199 * The response.
|
Chris@18
|
200 *
|
Chris@18
|
201 * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
|
Chris@18
|
202 * Thrown when access to the entity is not allowed.
|
Chris@18
|
203 */
|
Chris@18
|
204 public function getIndividual(EntityInterface $entity, Request $request) {
|
Chris@18
|
205 $resource_object = $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
|
Chris@18
|
206 if ($resource_object instanceof EntityAccessDeniedHttpException) {
|
Chris@18
|
207 throw $resource_object;
|
Chris@18
|
208 }
|
Chris@18
|
209 $primary_data = new ResourceObjectData([$resource_object], 1);
|
Chris@18
|
210 $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data));
|
Chris@18
|
211 return $response;
|
Chris@18
|
212 }
|
Chris@18
|
213
|
Chris@18
|
214 /**
|
Chris@18
|
215 * Creates an individual entity.
|
Chris@18
|
216 *
|
Chris@18
|
217 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
218 * The JSON:API resource type for the request to be served.
|
Chris@18
|
219 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
220 * The request object.
|
Chris@18
|
221 *
|
Chris@18
|
222 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
223 * The response.
|
Chris@18
|
224 *
|
Chris@18
|
225 * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
|
Chris@18
|
226 * Thrown when the entity already exists.
|
Chris@18
|
227 * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
|
Chris@18
|
228 * Thrown when the entity does not pass validation.
|
Chris@18
|
229 */
|
Chris@18
|
230 public function createIndividual(ResourceType $resource_type, Request $request) {
|
Chris@18
|
231 $parsed_entity = $this->deserialize($resource_type, $request, JsonApiDocumentTopLevel::class);
|
Chris@18
|
232
|
Chris@18
|
233 if ($parsed_entity instanceof FieldableEntityInterface) {
|
Chris@18
|
234 // Only check 'edit' permissions for fields that were actually submitted
|
Chris@18
|
235 // by the user. Field access makes no distinction between 'create' and
|
Chris@18
|
236 // 'update', so the 'edit' operation is used here.
|
Chris@18
|
237 $document = Json::decode($request->getContent());
|
Chris@18
|
238 foreach (['attributes', 'relationships'] as $data_member_name) {
|
Chris@18
|
239 if (isset($document['data'][$data_member_name])) {
|
Chris@18
|
240 $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) {
|
Chris@18
|
241 return $resource_type->getInternalName($public_field_name);
|
Chris@18
|
242 }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) {
|
Chris@18
|
243 return $resource_type->hasField($internal_field_name);
|
Chris@18
|
244 });
|
Chris@18
|
245 foreach ($valid_names as $field_name) {
|
Chris@18
|
246 $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE);
|
Chris@18
|
247 if (!$field_access->isAllowed()) {
|
Chris@18
|
248 $public_field_name = $resource_type->getPublicName($field_name);
|
Chris@18
|
249 throw new EntityAccessDeniedHttpException(NULL, $field_access, "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', $public_field_name));
|
Chris@18
|
250 }
|
Chris@18
|
251 }
|
Chris@18
|
252 }
|
Chris@18
|
253 }
|
Chris@18
|
254 }
|
Chris@18
|
255
|
Chris@18
|
256 static::validate($parsed_entity);
|
Chris@18
|
257
|
Chris@18
|
258 // Return a 409 Conflict response in accordance with the JSON:API spec. See
|
Chris@18
|
259 // http://jsonapi.org/format/#crud-creating-responses-409.
|
Chris@18
|
260 if ($this->entityExists($parsed_entity)) {
|
Chris@18
|
261 throw new ConflictHttpException('Conflict: Entity already exists.');
|
Chris@18
|
262 }
|
Chris@18
|
263
|
Chris@18
|
264 $parsed_entity->save();
|
Chris@18
|
265
|
Chris@18
|
266 // Build response object.
|
Chris@18
|
267 $resource_object = ResourceObject::createFromEntity($resource_type, $parsed_entity);
|
Chris@18
|
268 $primary_data = new ResourceObjectData([$resource_object], 1);
|
Chris@18
|
269 $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data), 201);
|
Chris@18
|
270
|
Chris@18
|
271 // According to JSON:API specification, when a new entity was created
|
Chris@18
|
272 // we should send "Location" header to the frontend.
|
Chris@18
|
273 if ($resource_type->isLocatable()) {
|
Chris@18
|
274 $url = $resource_object->toUrl()->setAbsolute()->toString(TRUE);
|
Chris@18
|
275 $response->addCacheableDependency($url);
|
Chris@18
|
276 $response->headers->set('Location', $url->getGeneratedUrl());
|
Chris@18
|
277 }
|
Chris@18
|
278
|
Chris@18
|
279 // Return response object with updated headers info.
|
Chris@18
|
280 return $response;
|
Chris@18
|
281 }
|
Chris@18
|
282
|
Chris@18
|
283 /**
|
Chris@18
|
284 * Patches an individual entity.
|
Chris@18
|
285 *
|
Chris@18
|
286 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
287 * The JSON:API resource type for the request to be served.
|
Chris@18
|
288 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
289 * The loaded entity.
|
Chris@18
|
290 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
291 * The request object.
|
Chris@18
|
292 *
|
Chris@18
|
293 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
294 * The response.
|
Chris@18
|
295 *
|
Chris@18
|
296 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
297 * Thrown when the selected entity does not match the id in th payload.
|
Chris@18
|
298 * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
|
Chris@18
|
299 * Thrown when the patched entity does not pass validation.
|
Chris@18
|
300 */
|
Chris@18
|
301 public function patchIndividual(ResourceType $resource_type, EntityInterface $entity, Request $request) {
|
Chris@18
|
302 if ($entity instanceof RevisionableInterface && !($entity->isLatestRevision() && $entity->isDefaultRevision())) {
|
Chris@18
|
303 throw new BadRequestHttpException('Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/jsonapi/issues/2795279.');
|
Chris@18
|
304 }
|
Chris@18
|
305
|
Chris@18
|
306 $parsed_entity = $this->deserialize($resource_type, $request, JsonApiDocumentTopLevel::class);
|
Chris@18
|
307
|
Chris@18
|
308 $body = Json::decode($request->getContent());
|
Chris@18
|
309 $data = $body['data'];
|
Chris@18
|
310 if ($data['id'] != $entity->uuid()) {
|
Chris@18
|
311 throw new BadRequestHttpException(sprintf(
|
Chris@18
|
312 'The selected entity (%s) does not match the ID in the payload (%s).',
|
Chris@18
|
313 $entity->uuid(),
|
Chris@18
|
314 $data['id']
|
Chris@18
|
315 ));
|
Chris@18
|
316 }
|
Chris@18
|
317 $data += ['attributes' => [], 'relationships' => []];
|
Chris@18
|
318 $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships']));
|
Chris@18
|
319
|
Chris@18
|
320 array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) {
|
Chris@18
|
321 $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name);
|
Chris@18
|
322 return $destination;
|
Chris@18
|
323 }, $entity);
|
Chris@18
|
324
|
Chris@18
|
325 static::validate($entity, $field_names);
|
Chris@18
|
326
|
Chris@18
|
327 // Set revision data details for revisionable entities.
|
Chris@18
|
328 if ($entity->getEntityType()->isRevisionable()) {
|
Chris@18
|
329 if ($bundle_entity_type = $entity->getEntityType()->getBundleEntityType()) {
|
Chris@18
|
330 $bundle_entity = $this->entityTypeManager->getStorage($bundle_entity_type)->load($entity->bundle());
|
Chris@18
|
331 if ($bundle_entity instanceof RevisionableEntityBundleInterface) {
|
Chris@18
|
332 $entity->setNewRevision($bundle_entity->shouldCreateNewRevision());
|
Chris@18
|
333 }
|
Chris@18
|
334 }
|
Chris@18
|
335 if ($entity instanceof RevisionLogInterface && $entity->isNewRevision()) {
|
Chris@18
|
336 $entity->setRevisionUserId($this->user->id());
|
Chris@18
|
337 $entity->setRevisionCreationTime($this->time->getRequestTime());
|
Chris@18
|
338 }
|
Chris@18
|
339 }
|
Chris@18
|
340
|
Chris@18
|
341 $entity->save();
|
Chris@18
|
342 $primary_data = new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1);
|
Chris@18
|
343 return $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data));
|
Chris@18
|
344 }
|
Chris@18
|
345
|
Chris@18
|
346 /**
|
Chris@18
|
347 * Deletes an individual entity.
|
Chris@18
|
348 *
|
Chris@18
|
349 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
350 * The loaded entity.
|
Chris@18
|
351 *
|
Chris@18
|
352 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
353 * The response.
|
Chris@18
|
354 */
|
Chris@18
|
355 public function deleteIndividual(EntityInterface $entity) {
|
Chris@18
|
356 $entity->delete();
|
Chris@18
|
357 return new ResourceResponse(NULL, 204);
|
Chris@18
|
358 }
|
Chris@18
|
359
|
Chris@18
|
360 /**
|
Chris@18
|
361 * Gets the collection of entities.
|
Chris@18
|
362 *
|
Chris@18
|
363 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
364 * The JSON:API resource type for the request to be served.
|
Chris@18
|
365 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
366 * The request object.
|
Chris@18
|
367 *
|
Chris@18
|
368 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
369 * The response.
|
Chris@18
|
370 *
|
Chris@18
|
371 * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
|
Chris@18
|
372 * Thrown when filtering on a config entity which does not support it.
|
Chris@18
|
373 */
|
Chris@18
|
374 public function getCollection(ResourceType $resource_type, Request $request) {
|
Chris@18
|
375 // Instantiate the query for the filtering.
|
Chris@18
|
376 $entity_type_id = $resource_type->getEntityTypeId();
|
Chris@18
|
377
|
Chris@18
|
378 $params = $this->getJsonApiParams($request, $resource_type);
|
Chris@18
|
379 $query_cacheability = new CacheableMetadata();
|
Chris@18
|
380 $query = $this->getCollectionQuery($resource_type, $params, $query_cacheability);
|
Chris@18
|
381
|
Chris@18
|
382 // If the request is for the latest revision, toggle it on entity query.
|
Chris@18
|
383 if ($request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE)) {
|
Chris@18
|
384 $query->latestRevision();
|
Chris@18
|
385 }
|
Chris@18
|
386
|
Chris@18
|
387 try {
|
Chris@18
|
388 $results = $this->executeQueryInRenderContext(
|
Chris@18
|
389 $query,
|
Chris@18
|
390 $query_cacheability
|
Chris@18
|
391 );
|
Chris@18
|
392 }
|
Chris@18
|
393 catch (\LogicException $e) {
|
Chris@18
|
394 // Ensure good DX when an entity query involves a config entity type.
|
Chris@18
|
395 // For example: getting users with a particular role, which is a config
|
Chris@18
|
396 // entity type: https://www.drupal.org/project/jsonapi/issues/2959445.
|
Chris@18
|
397 // @todo Remove the message parsing in https://www.drupal.org/project/drupal/issues/3028967.
|
Chris@18
|
398 if (strpos($e->getMessage(), 'Getting the base fields is not supported for entity type') === 0) {
|
Chris@18
|
399 preg_match('/entity type (.*)\./', $e->getMessage(), $matches);
|
Chris@18
|
400 $config_entity_type_id = $matches[1];
|
Chris@18
|
401 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', 'url.query_args:filter']);
|
Chris@18
|
402 throw new CacheableBadRequestHttpException($cacheability, sprintf("Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a %s config entity.", $config_entity_type_id));
|
Chris@18
|
403 }
|
Chris@18
|
404 else {
|
Chris@18
|
405 throw $e;
|
Chris@18
|
406 }
|
Chris@18
|
407 }
|
Chris@18
|
408
|
Chris@18
|
409 $storage = $this->entityTypeManager->getStorage($entity_type_id);
|
Chris@18
|
410 // We request N+1 items to find out if there is a next page for the pager.
|
Chris@18
|
411 // We may need to remove that extra item before loading the entities.
|
Chris@18
|
412 $pager_size = $query->getMetaData('pager_size');
|
Chris@18
|
413 if ($has_next_page = $pager_size < count($results)) {
|
Chris@18
|
414 // Drop the last result.
|
Chris@18
|
415 array_pop($results);
|
Chris@18
|
416 }
|
Chris@18
|
417 // Each item of the collection data contains an array with 'entity' and
|
Chris@18
|
418 // 'access' elements.
|
Chris@18
|
419 $collection_data = $this->loadEntitiesWithAccess($storage, $results, $request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE));
|
Chris@18
|
420 $primary_data = new ResourceObjectData($collection_data);
|
Chris@18
|
421 $primary_data->setHasNextPage($has_next_page);
|
Chris@18
|
422
|
Chris@18
|
423 // Calculate all the results and pass into a JSON:API Data object.
|
Chris@18
|
424 $count_query_cacheability = new CacheableMetadata();
|
Chris@18
|
425 if ($resource_type->includeCount()) {
|
Chris@18
|
426 $count_query = $this->getCollectionCountQuery($resource_type, $params, $count_query_cacheability);
|
Chris@18
|
427 $total_results = $this->executeQueryInRenderContext(
|
Chris@18
|
428 $count_query,
|
Chris@18
|
429 $count_query_cacheability
|
Chris@18
|
430 );
|
Chris@18
|
431
|
Chris@18
|
432 $primary_data->setTotalCount($total_results);
|
Chris@18
|
433 }
|
Chris@18
|
434
|
Chris@18
|
435 $response = $this->respondWithCollection($primary_data, $this->getIncludes($request, $primary_data), $request, $resource_type, $params[OffsetPage::KEY_NAME]);
|
Chris@18
|
436
|
Chris@18
|
437 $response->addCacheableDependency($query_cacheability);
|
Chris@18
|
438 $response->addCacheableDependency($count_query_cacheability);
|
Chris@18
|
439 $response->addCacheableDependency((new CacheableMetadata())
|
Chris@18
|
440 ->addCacheContexts([
|
Chris@18
|
441 'url.query_args:filter',
|
Chris@18
|
442 'url.query_args:sort',
|
Chris@18
|
443 'url.query_args:page',
|
Chris@18
|
444 ]));
|
Chris@18
|
445
|
Chris@18
|
446 if ($resource_type->isVersionable()) {
|
Chris@18
|
447 $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts([ResourceVersionRouteEnhancer::CACHE_CONTEXT]));
|
Chris@18
|
448 }
|
Chris@18
|
449
|
Chris@18
|
450 return $response;
|
Chris@18
|
451 }
|
Chris@18
|
452
|
Chris@18
|
453 /**
|
Chris@18
|
454 * Executes the query in a render context, to catch bubbled cacheability.
|
Chris@18
|
455 *
|
Chris@18
|
456 * @param \Drupal\Core\Entity\Query\QueryInterface $query
|
Chris@18
|
457 * The query to execute to get the return results.
|
Chris@18
|
458 * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
|
Chris@18
|
459 * The value object to carry the query cacheability.
|
Chris@18
|
460 *
|
Chris@18
|
461 * @return int|array
|
Chris@18
|
462 * Returns an integer for count queries or an array of IDs. The values of
|
Chris@18
|
463 * the array are always entity IDs. The keys will be revision IDs if the
|
Chris@18
|
464 * entity supports revision and entity IDs if not.
|
Chris@18
|
465 *
|
Chris@18
|
466 * @see node_query_node_access_alter()
|
Chris@18
|
467 * @see https://www.drupal.org/project/drupal/issues/2557815
|
Chris@18
|
468 * @see https://www.drupal.org/project/drupal/issues/2794385
|
Chris@18
|
469 * @todo Remove this after https://www.drupal.org/project/drupal/issues/3028976 is fixed.
|
Chris@18
|
470 */
|
Chris@18
|
471 protected function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) {
|
Chris@18
|
472 $context = new RenderContext();
|
Chris@18
|
473 $results = $this->renderer->executeInRenderContext($context, function () use ($query) {
|
Chris@18
|
474 return $query->execute();
|
Chris@18
|
475 });
|
Chris@18
|
476 if (!$context->isEmpty()) {
|
Chris@18
|
477 $query_cacheability->addCacheableDependency($context->pop());
|
Chris@18
|
478 }
|
Chris@18
|
479 return $results;
|
Chris@18
|
480 }
|
Chris@18
|
481
|
Chris@18
|
482 /**
|
Chris@18
|
483 * Gets the related resource.
|
Chris@18
|
484 *
|
Chris@18
|
485 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
486 * The JSON:API resource type for the request to be served.
|
Chris@18
|
487 * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
|
Chris@18
|
488 * The requested entity.
|
Chris@18
|
489 * @param string $related
|
Chris@18
|
490 * The related field name.
|
Chris@18
|
491 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
492 * The request object.
|
Chris@18
|
493 *
|
Chris@18
|
494 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
495 * The response.
|
Chris@18
|
496 */
|
Chris@18
|
497 public function getRelated(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) {
|
Chris@18
|
498 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
|
Chris@18
|
499 $field_list = $entity->get($resource_type->getInternalName($related));
|
Chris@18
|
500
|
Chris@18
|
501 // Remove the entities pointing to a resource that may be disabled. Even
|
Chris@18
|
502 // though the normalizer skips disabled references, we can avoid unnecessary
|
Chris@18
|
503 // work by checking here too.
|
Chris@18
|
504 /* @var \Drupal\Core\Entity\EntityInterface[] $referenced_entities */
|
Chris@18
|
505 $referenced_entities = array_filter(
|
Chris@18
|
506 $field_list->referencedEntities(),
|
Chris@18
|
507 function (EntityInterface $entity) {
|
Chris@18
|
508 return (bool) $this->resourceTypeRepository->get(
|
Chris@18
|
509 $entity->getEntityTypeId(),
|
Chris@18
|
510 $entity->bundle()
|
Chris@18
|
511 );
|
Chris@18
|
512 }
|
Chris@18
|
513 );
|
Chris@18
|
514 $collection_data = [];
|
Chris@18
|
515 foreach ($referenced_entities as $referenced_entity) {
|
Chris@18
|
516 $collection_data[] = $this->entityAccessChecker->getAccessCheckedResourceObject($referenced_entity);
|
Chris@18
|
517 }
|
Chris@18
|
518 $primary_data = new ResourceObjectData($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality());
|
Chris@18
|
519 $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data));
|
Chris@18
|
520
|
Chris@18
|
521 // $response does not contain the entity list cache tag. We add the
|
Chris@18
|
522 // cacheable metadata for the finite list of entities in the relationship.
|
Chris@18
|
523 $response->addCacheableDependency($entity);
|
Chris@18
|
524
|
Chris@18
|
525 return $response;
|
Chris@18
|
526 }
|
Chris@18
|
527
|
Chris@18
|
528 /**
|
Chris@18
|
529 * Gets the relationship of an entity.
|
Chris@18
|
530 *
|
Chris@18
|
531 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
532 * The base JSON:API resource type for the request to be served.
|
Chris@18
|
533 * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
|
Chris@18
|
534 * The requested entity.
|
Chris@18
|
535 * @param string $related
|
Chris@18
|
536 * The related field name.
|
Chris@18
|
537 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
538 * The request object.
|
Chris@18
|
539 * @param int $response_code
|
Chris@18
|
540 * The response code. Defaults to 200.
|
Chris@18
|
541 *
|
Chris@18
|
542 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
543 * The response.
|
Chris@18
|
544 */
|
Chris@18
|
545 public function getRelationship(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request, $response_code = 200) {
|
Chris@18
|
546 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
|
Chris@18
|
547 $field_list = $entity->get($resource_type->getInternalName($related));
|
Chris@18
|
548 // Access will have already been checked by the RelationshipFieldAccess
|
Chris@18
|
549 // service, so we don't need to call ::getAccessCheckedResourceObject().
|
Chris@18
|
550 $resource_object = ResourceObject::createFromEntity($resource_type, $entity);
|
Chris@18
|
551 $relationship_object_urls = EntityReferenceFieldNormalizer::getRelationshipLinks($resource_object, $related);
|
Chris@18
|
552 $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $resource_object), $response_code, [], array_reduce(array_keys($relationship_object_urls), function (LinkCollection $links, $key) use ($relationship_object_urls) {
|
Chris@18
|
553 return $links->withLink($key, new Link(new CacheableMetadata(), $relationship_object_urls[$key], [$key]));
|
Chris@18
|
554 }, new LinkCollection([])));
|
Chris@18
|
555 // Add the host entity as a cacheable dependency.
|
Chris@18
|
556 $response->addCacheableDependency($entity);
|
Chris@18
|
557 return $response;
|
Chris@18
|
558 }
|
Chris@18
|
559
|
Chris@18
|
560 /**
|
Chris@18
|
561 * Adds a relationship to a to-many relationship.
|
Chris@18
|
562 *
|
Chris@18
|
563 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
564 * The base JSON:API resource type for the request to be served.
|
Chris@18
|
565 * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
|
Chris@18
|
566 * The requested entity.
|
Chris@18
|
567 * @param string $related
|
Chris@18
|
568 * The related field name.
|
Chris@18
|
569 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
570 * The request object.
|
Chris@18
|
571 *
|
Chris@18
|
572 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
573 * The response.
|
Chris@18
|
574 *
|
Chris@18
|
575 * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
|
Chris@18
|
576 * Thrown when the current user is not allowed to PATCH the selected
|
Chris@18
|
577 * field(s).
|
Chris@18
|
578 * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
|
Chris@18
|
579 * Thrown when POSTing to a "to-one" relationship.
|
Chris@18
|
580 * @throws \Drupal\Core\Entity\EntityStorageException
|
Chris@18
|
581 * Thrown when the underlying entity cannot be saved.
|
Chris@18
|
582 * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
|
Chris@18
|
583 * Thrown when the updated entity does not pass validation.
|
Chris@18
|
584 */
|
Chris@18
|
585 public function addToRelationshipData(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) {
|
Chris@18
|
586 $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
|
Chris@18
|
587 $related = $resource_type->getInternalName($related);
|
Chris@18
|
588 // According to the specification, you are only allowed to POST to a
|
Chris@18
|
589 // relationship if it is a to-many relationship.
|
Chris@18
|
590 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
|
Chris@18
|
591 $field_list = $entity->{$related};
|
Chris@18
|
592 /* @var \Drupal\field\Entity\FieldConfig $field_definition */
|
Chris@18
|
593 $field_definition = $field_list->getFieldDefinition();
|
Chris@18
|
594 $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
|
Chris@18
|
595 if (!$is_multiple) {
|
Chris@18
|
596 throw new ConflictHttpException(sprintf('You can only POST to to-many relationships. %s is a to-one relationship.', $related));
|
Chris@18
|
597 }
|
Chris@18
|
598
|
Chris@18
|
599 $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
|
Chris@18
|
600 $new_resource_identifiers = array_udiff(
|
Chris@18
|
601 ResourceIdentifier::deduplicate(array_merge($original_resource_identifiers, $resource_identifiers)),
|
Chris@18
|
602 $original_resource_identifiers,
|
Chris@18
|
603 [ResourceIdentifier::class, 'compare']
|
Chris@18
|
604 );
|
Chris@18
|
605
|
Chris@18
|
606 // There are no relationships that need to be added so we can exit early.
|
Chris@18
|
607 if (empty($new_resource_identifiers)) {
|
Chris@18
|
608 $status = static::relationshipResponseRequiresBody($resource_identifiers, $original_resource_identifiers) ? 200 : 204;
|
Chris@18
|
609 return $this->getRelationship($resource_type, $entity, $related, $request, $status);
|
Chris@18
|
610 }
|
Chris@18
|
611
|
Chris@18
|
612 $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
|
Chris@18
|
613 foreach ($new_resource_identifiers as $new_resource_identifier) {
|
Chris@18
|
614 $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()];
|
Chris@18
|
615 // Remove `arity` from the received extra properties, otherwise this
|
Chris@18
|
616 // will fail field validation.
|
Chris@18
|
617 $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
|
Chris@18
|
618 $field_list->appendItem($new_field_value);
|
Chris@18
|
619 }
|
Chris@18
|
620
|
Chris@18
|
621 $this->validate($entity);
|
Chris@18
|
622 $entity->save();
|
Chris@18
|
623
|
Chris@18
|
624 $final_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
|
Chris@18
|
625 $status = static::relationshipResponseRequiresBody($resource_identifiers, $final_resource_identifiers) ? 200 : 204;
|
Chris@18
|
626 return $this->getRelationship($resource_type, $entity, $related, $request, $status);
|
Chris@18
|
627 }
|
Chris@18
|
628
|
Chris@18
|
629 /**
|
Chris@18
|
630 * Updates the relationship of an entity.
|
Chris@18
|
631 *
|
Chris@18
|
632 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
633 * The base JSON:API resource type for the request to be served.
|
Chris@18
|
634 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
635 * The requested entity.
|
Chris@18
|
636 * @param string $related
|
Chris@18
|
637 * The related field name.
|
Chris@18
|
638 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
639 * The request object.
|
Chris@18
|
640 *
|
Chris@18
|
641 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
642 * The response.
|
Chris@18
|
643 *
|
Chris@18
|
644 * @throws \Drupal\Core\Entity\EntityStorageException
|
Chris@18
|
645 * Thrown when the underlying entity cannot be saved.
|
Chris@18
|
646 * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
|
Chris@18
|
647 * Thrown when the updated entity does not pass validation.
|
Chris@18
|
648 */
|
Chris@18
|
649 public function replaceRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) {
|
Chris@18
|
650 $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
|
Chris@18
|
651 $related = $resource_type->getInternalName($related);
|
Chris@18
|
652 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $resource_identifiers */
|
Chris@18
|
653 // According to the specification, PATCH works a little bit different if the
|
Chris@18
|
654 // relationship is to-one or to-many.
|
Chris@18
|
655 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
|
Chris@18
|
656 $field_list = $entity->{$related};
|
Chris@18
|
657 $field_definition = $field_list->getFieldDefinition();
|
Chris@18
|
658 $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
|
Chris@18
|
659 $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship';
|
Chris@18
|
660 $this->{$method}($entity, $resource_identifiers, $field_definition);
|
Chris@18
|
661 $this->validate($entity);
|
Chris@18
|
662 $entity->save();
|
Chris@18
|
663 $requires_response = static::relationshipResponseRequiresBody($resource_identifiers, ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list));
|
Chris@18
|
664 return $this->getRelationship($resource_type, $entity, $related, $request, $requires_response ? 200 : 204);
|
Chris@18
|
665 }
|
Chris@18
|
666
|
Chris@18
|
667 /**
|
Chris@18
|
668 * Update a to-one relationship.
|
Chris@18
|
669 *
|
Chris@18
|
670 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
671 * The requested entity.
|
Chris@18
|
672 * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
|
Chris@18
|
673 * The client-sent resource identifiers which should be set on the given
|
Chris@18
|
674 * entity. Should be an empty array or an array with a single value.
|
Chris@18
|
675 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
Chris@18
|
676 * The field definition of the entity field to be updated.
|
Chris@18
|
677 *
|
Chris@18
|
678 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
679 * Thrown when a "to-one" relationship is not provided.
|
Chris@18
|
680 */
|
Chris@18
|
681 protected function doPatchIndividualRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) {
|
Chris@18
|
682 if (count($resource_identifiers) > 1) {
|
Chris@18
|
683 throw new BadRequestHttpException(sprintf('Provide a single relationship so to-one relationship fields (%s).', $field_definition->getName()));
|
Chris@18
|
684 }
|
Chris@18
|
685 $this->doPatchMultipleRelationship($entity, $resource_identifiers, $field_definition);
|
Chris@18
|
686 }
|
Chris@18
|
687
|
Chris@18
|
688 /**
|
Chris@18
|
689 * Update a to-many relationship.
|
Chris@18
|
690 *
|
Chris@18
|
691 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
692 * The requested entity.
|
Chris@18
|
693 * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
|
Chris@18
|
694 * The client-sent resource identifiers which should be set on the given
|
Chris@18
|
695 * entity.
|
Chris@18
|
696 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
Chris@18
|
697 * The field definition of the entity field to be updated.
|
Chris@18
|
698 */
|
Chris@18
|
699 protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) {
|
Chris@18
|
700 $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
|
Chris@18
|
701 $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) {
|
Chris@18
|
702 $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()];
|
Chris@18
|
703 // Remove `arity` from the received extra properties, otherwise this
|
Chris@18
|
704 // will fail field validation.
|
Chris@18
|
705 $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
|
Chris@18
|
706 return $field_properties;
|
Chris@18
|
707 }, $resource_identifiers);
|
Chris@18
|
708 }
|
Chris@18
|
709
|
Chris@18
|
710 /**
|
Chris@18
|
711 * Deletes the relationship of an entity.
|
Chris@18
|
712 *
|
Chris@18
|
713 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
714 * The base JSON:API resource type for the request to be served.
|
Chris@18
|
715 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
716 * The requested entity.
|
Chris@18
|
717 * @param string $related
|
Chris@18
|
718 * The related field name.
|
Chris@18
|
719 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
720 * The request object.
|
Chris@18
|
721 *
|
Chris@18
|
722 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
723 * The response.
|
Chris@18
|
724 *
|
Chris@18
|
725 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
726 * Thrown when not body was provided for the DELETE operation.
|
Chris@18
|
727 * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
|
Chris@18
|
728 * Thrown when deleting a "to-one" relationship.
|
Chris@18
|
729 * @throws \Drupal\Core\Entity\EntityStorageException
|
Chris@18
|
730 * Thrown when the underlying entity cannot be saved.
|
Chris@18
|
731 */
|
Chris@18
|
732 public function removeFromRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) {
|
Chris@18
|
733 $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
|
Chris@18
|
734 /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
|
Chris@18
|
735 $field_list = $entity->{$related};
|
Chris@18
|
736 $is_multiple = $field_list->getFieldDefinition()
|
Chris@18
|
737 ->getFieldStorageDefinition()
|
Chris@18
|
738 ->isMultiple();
|
Chris@18
|
739 if (!$is_multiple) {
|
Chris@18
|
740 throw new ConflictHttpException(sprintf('You can only DELETE from to-many relationships. %s is a to-one relationship.', $related));
|
Chris@18
|
741 }
|
Chris@18
|
742
|
Chris@18
|
743 // Compute the list of current values and remove the ones in the payload.
|
Chris@18
|
744 $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
|
Chris@18
|
745 $removed_resource_identifiers = array_uintersect($resource_identifiers, $original_resource_identifiers, [ResourceIdentifier::class, 'compare']);
|
Chris@18
|
746 $deltas_to_be_removed = [];
|
Chris@18
|
747 foreach ($removed_resource_identifiers as $removed_resource_identifier) {
|
Chris@18
|
748 foreach ($original_resource_identifiers as $delta => $existing_resource_identifier) {
|
Chris@18
|
749 // Identify the field item deltas which should be removed.
|
Chris@18
|
750 if (ResourceIdentifier::isDuplicate($removed_resource_identifier, $existing_resource_identifier)) {
|
Chris@18
|
751 $deltas_to_be_removed[] = $delta;
|
Chris@18
|
752 }
|
Chris@18
|
753 }
|
Chris@18
|
754 }
|
Chris@18
|
755 // Field item deltas are reset when an item is removed. This removes
|
Chris@18
|
756 // items in descending order so that the deltas yet to be removed will
|
Chris@18
|
757 // continue to exist.
|
Chris@18
|
758 rsort($deltas_to_be_removed);
|
Chris@18
|
759 foreach ($deltas_to_be_removed as $delta) {
|
Chris@18
|
760 $field_list->removeItem($delta);
|
Chris@18
|
761 }
|
Chris@18
|
762
|
Chris@18
|
763 // Save the entity and return the response object.
|
Chris@18
|
764 static::validate($entity);
|
Chris@18
|
765 $entity->save();
|
Chris@18
|
766 return $this->getRelationship($resource_type, $entity, $related, $request, 204);
|
Chris@18
|
767 }
|
Chris@18
|
768
|
Chris@18
|
769 /**
|
Chris@18
|
770 * Deserializes a request body, if any.
|
Chris@18
|
771 *
|
Chris@18
|
772 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
773 * The JSON:API resource type for the current request.
|
Chris@18
|
774 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
775 * The request object.
|
Chris@18
|
776 * @param string $class
|
Chris@18
|
777 * The class into which the request data needs to be deserialized.
|
Chris@18
|
778 * @param string $relationship_field_name
|
Chris@18
|
779 * The public relationship field name of the data to be deserialized if the
|
Chris@18
|
780 * incoming request is for a relationship update. Not required for non-
|
Chris@18
|
781 * relationship requests.
|
Chris@18
|
782 *
|
Chris@18
|
783 * @return array
|
Chris@18
|
784 * An object normalization.
|
Chris@18
|
785 *
|
Chris@18
|
786 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
787 * Thrown if the request body cannot be decoded, or when no request body was
|
Chris@18
|
788 * provided with a POST or PATCH request.
|
Chris@18
|
789 * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
|
Chris@18
|
790 * Thrown if the request body cannot be denormalized.
|
Chris@18
|
791 */
|
Chris@18
|
792 protected function deserialize(ResourceType $resource_type, Request $request, $class, $relationship_field_name = NULL) {
|
Chris@18
|
793 assert($class === JsonApiDocumentTopLevel::class || $class === ResourceIdentifier::class && !empty($relationship_field_name) && is_string($relationship_field_name));
|
Chris@18
|
794 $received = (string) $request->getContent();
|
Chris@18
|
795 if (!$received) {
|
Chris@18
|
796 assert($request->isMethod('POST') || $request->isMethod('PATCH') || $request->isMethod('DELETE'));
|
Chris@18
|
797 if ($request->isMethod('DELETE') && $relationship_field_name) {
|
Chris@18
|
798 throw new BadRequestHttpException(sprintf('You need to provide a body for DELETE operations on a relationship (%s).', $relationship_field_name));
|
Chris@18
|
799 }
|
Chris@18
|
800 else {
|
Chris@18
|
801 throw new BadRequestHttpException('Empty request body.');
|
Chris@18
|
802 }
|
Chris@18
|
803 }
|
Chris@18
|
804 // First decode the request data. We can then determine if the serialized
|
Chris@18
|
805 // data was malformed.
|
Chris@18
|
806 try {
|
Chris@18
|
807 $decoded = $this->serializer->decode($received, 'api_json');
|
Chris@18
|
808 }
|
Chris@18
|
809 catch (UnexpectedValueException $e) {
|
Chris@18
|
810 // If an exception was thrown at this stage, there was a problem decoding
|
Chris@18
|
811 // the data. Throw a 400 HTTP exception.
|
Chris@18
|
812 throw new BadRequestHttpException($e->getMessage());
|
Chris@18
|
813 }
|
Chris@18
|
814
|
Chris@18
|
815 try {
|
Chris@18
|
816 $context = ['resource_type' => $resource_type];
|
Chris@18
|
817 if ($relationship_field_name) {
|
Chris@18
|
818 $context['related'] = $resource_type->getInternalName($relationship_field_name);
|
Chris@18
|
819 }
|
Chris@18
|
820 return $this->serializer->denormalize($decoded, $class, 'api_json', $context);
|
Chris@18
|
821 }
|
Chris@18
|
822 // These two serialization exception types mean there was a problem with
|
Chris@18
|
823 // the structure of the decoded data and it's not valid.
|
Chris@18
|
824 catch (UnexpectedValueException $e) {
|
Chris@18
|
825 throw new UnprocessableHttpEntityException($e->getMessage());
|
Chris@18
|
826 }
|
Chris@18
|
827 catch (InvalidArgumentException $e) {
|
Chris@18
|
828 throw new UnprocessableHttpEntityException($e->getMessage());
|
Chris@18
|
829 }
|
Chris@18
|
830 }
|
Chris@18
|
831
|
Chris@18
|
832 /**
|
Chris@18
|
833 * Gets a basic query for a collection.
|
Chris@18
|
834 *
|
Chris@18
|
835 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
836 * The base JSON:API resource type for the query.
|
Chris@18
|
837 * @param array $params
|
Chris@18
|
838 * The parameters for the query.
|
Chris@18
|
839 * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
|
Chris@18
|
840 * Collects cacheability for the query.
|
Chris@18
|
841 *
|
Chris@18
|
842 * @return \Drupal\Core\Entity\Query\QueryInterface
|
Chris@18
|
843 * A new query.
|
Chris@18
|
844 */
|
Chris@18
|
845 protected function getCollectionQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) {
|
Chris@18
|
846 $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
|
Chris@18
|
847 $entity_storage = $this->entityTypeManager->getStorage($resource_type->getEntityTypeId());
|
Chris@18
|
848
|
Chris@18
|
849 $query = $entity_storage->getQuery();
|
Chris@18
|
850
|
Chris@18
|
851 // Ensure that access checking is performed on the query.
|
Chris@18
|
852 $query->accessCheck(TRUE);
|
Chris@18
|
853
|
Chris@18
|
854 // Compute and apply an entity query condition from the filter parameter.
|
Chris@18
|
855 if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) {
|
Chris@18
|
856 $query->condition($filter->queryCondition($query));
|
Chris@18
|
857 TemporaryQueryGuard::setFieldManager($this->fieldManager);
|
Chris@18
|
858 TemporaryQueryGuard::setModuleHandler(\Drupal::moduleHandler());
|
Chris@18
|
859 TemporaryQueryGuard::applyAccessControls($filter, $query, $query_cacheability);
|
Chris@18
|
860 }
|
Chris@18
|
861
|
Chris@18
|
862 // Apply any sorts to the entity query.
|
Chris@18
|
863 if (isset($params[Sort::KEY_NAME]) && $sort = $params[Sort::KEY_NAME]) {
|
Chris@18
|
864 foreach ($sort->fields() as $field) {
|
Chris@18
|
865 $path = $this->fieldResolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $field[Sort::PATH_KEY]);
|
Chris@18
|
866 $direction = isset($field[Sort::DIRECTION_KEY]) ? $field[Sort::DIRECTION_KEY] : 'ASC';
|
Chris@18
|
867 $langcode = isset($field[Sort::LANGUAGE_KEY]) ? $field[Sort::LANGUAGE_KEY] : NULL;
|
Chris@18
|
868 $query->sort($path, $direction, $langcode);
|
Chris@18
|
869 }
|
Chris@18
|
870 }
|
Chris@18
|
871
|
Chris@18
|
872 // Apply any pagination options to the query.
|
Chris@18
|
873 if (isset($params[OffsetPage::KEY_NAME])) {
|
Chris@18
|
874 $pagination = $params[OffsetPage::KEY_NAME];
|
Chris@18
|
875 }
|
Chris@18
|
876 else {
|
Chris@18
|
877 $pagination = new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX);
|
Chris@18
|
878 }
|
Chris@18
|
879 // Add one extra element to the page to see if there are more pages needed.
|
Chris@18
|
880 $query->range($pagination->getOffset(), $pagination->getSize() + 1);
|
Chris@18
|
881 $query->addMetaData('pager_size', (int) $pagination->getSize());
|
Chris@18
|
882
|
Chris@18
|
883 // Limit this query to the bundle type for this resource.
|
Chris@18
|
884 $bundle = $resource_type->getBundle();
|
Chris@18
|
885 if ($bundle && ($bundle_key = $entity_type->getKey('bundle'))) {
|
Chris@18
|
886 $query->condition(
|
Chris@18
|
887 $bundle_key, $bundle
|
Chris@18
|
888 );
|
Chris@18
|
889 }
|
Chris@18
|
890
|
Chris@18
|
891 return $query;
|
Chris@18
|
892 }
|
Chris@18
|
893
|
Chris@18
|
894 /**
|
Chris@18
|
895 * Gets a basic query for a collection count.
|
Chris@18
|
896 *
|
Chris@18
|
897 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
898 * The base JSON:API resource type for the query.
|
Chris@18
|
899 * @param array $params
|
Chris@18
|
900 * The parameters for the query.
|
Chris@18
|
901 * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
|
Chris@18
|
902 * Collects cacheability for the query.
|
Chris@18
|
903 *
|
Chris@18
|
904 * @return \Drupal\Core\Entity\Query\QueryInterface
|
Chris@18
|
905 * A new query.
|
Chris@18
|
906 */
|
Chris@18
|
907 protected function getCollectionCountQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) {
|
Chris@18
|
908 // Reset the range to get all the available results.
|
Chris@18
|
909 return $this->getCollectionQuery($resource_type, $params, $query_cacheability)->range()->count();
|
Chris@18
|
910 }
|
Chris@18
|
911
|
Chris@18
|
912 /**
|
Chris@18
|
913 * Loads the entity targeted by a resource identifier.
|
Chris@18
|
914 *
|
Chris@18
|
915 * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $resource_identifier
|
Chris@18
|
916 * A resource identifier.
|
Chris@18
|
917 *
|
Chris@18
|
918 * @return \Drupal\Core\Entity\EntityInterface
|
Chris@18
|
919 * The entity targeted by a resource identifier.
|
Chris@18
|
920 *
|
Chris@18
|
921 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
922 * Thrown if the given resource identifier targets a resource type or
|
Chris@18
|
923 * resource which does not exist.
|
Chris@18
|
924 */
|
Chris@18
|
925 protected function getEntityFromResourceIdentifier(ResourceIdentifier $resource_identifier) {
|
Chris@18
|
926 $resource_type_name = $resource_identifier->getTypeName();
|
Chris@18
|
927 if (!($target_resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name))) {
|
Chris@18
|
928 throw new BadRequestHttpException("The resource type `{$resource_type_name}` does not exist.");
|
Chris@18
|
929 }
|
Chris@18
|
930 $id = $resource_identifier->getId();
|
Chris@18
|
931 if (!($targeted_resource = $this->entityRepository->loadEntityByUuid($target_resource_type->getEntityTypeId(), $id))) {
|
Chris@18
|
932 throw new BadRequestHttpException("The targeted `{$resource_type_name}` resource with ID `{$id}` does not exist.");
|
Chris@18
|
933 }
|
Chris@18
|
934 return $targeted_resource;
|
Chris@18
|
935 }
|
Chris@18
|
936
|
Chris@18
|
937 /**
|
Chris@18
|
938 * Determines if the client needs to be updated with new relationship data.
|
Chris@18
|
939 *
|
Chris@18
|
940 * @param array $received_resource_identifiers
|
Chris@18
|
941 * The array of resource identifiers given by the client.
|
Chris@18
|
942 * @param array $final_resource_identifiers
|
Chris@18
|
943 * The final array of resource identifiers after applying the requested
|
Chris@18
|
944 * changes.
|
Chris@18
|
945 *
|
Chris@18
|
946 * @return bool
|
Chris@18
|
947 * Whether the final array of resource identifiers is different than the
|
Chris@18
|
948 * client-sent data.
|
Chris@18
|
949 */
|
Chris@18
|
950 protected static function relationshipResponseRequiresBody(array $received_resource_identifiers, array $final_resource_identifiers) {
|
Chris@18
|
951 return !empty(array_udiff($final_resource_identifiers, $received_resource_identifiers, [ResourceIdentifier::class, 'compare']));
|
Chris@18
|
952 }
|
Chris@18
|
953
|
Chris@18
|
954 /**
|
Chris@18
|
955 * Builds a response with the appropriate wrapped document.
|
Chris@18
|
956 *
|
Chris@18
|
957 * @param mixed $data
|
Chris@18
|
958 * The data to wrap.
|
Chris@18
|
959 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
960 * The request object.
|
Chris@18
|
961 * @param \Drupal\jsonapi\JsonApiResource\IncludedData $includes
|
Chris@18
|
962 * The resources to be included in the document. Use NullData if
|
Chris@18
|
963 * there should be no included resources in the document.
|
Chris@18
|
964 * @param int $response_code
|
Chris@18
|
965 * The response code.
|
Chris@18
|
966 * @param array $headers
|
Chris@18
|
967 * An array of response headers.
|
Chris@18
|
968 * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
Chris@18
|
969 * The URLs to which to link. A 'self' link is added automatically.
|
Chris@18
|
970 * @param array $meta
|
Chris@18
|
971 * (optional) The top-level metadata.
|
Chris@18
|
972 *
|
Chris@18
|
973 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
974 * The response.
|
Chris@18
|
975 */
|
Chris@18
|
976 protected function buildWrappedResponse($data, Request $request, IncludedData $includes, $response_code = 200, array $headers = [], LinkCollection $links = NULL, array $meta = []) {
|
Chris@18
|
977 assert($data instanceof Data || $data instanceof FieldItemListInterface);
|
Chris@18
|
978 $links = ($links ?: new LinkCollection([]));
|
Chris@18
|
979 if (!$links->hasLinkWithKey('self')) {
|
Chris@18
|
980 $self_link = new Link(new CacheableMetadata(), self::getRequestLink($request), ['self']);
|
Chris@18
|
981 $links = $links->withLink('self', $self_link);
|
Chris@18
|
982 }
|
Chris@18
|
983 $response = new ResourceResponse(new JsonApiDocumentTopLevel($data, $includes, $links, $meta), $response_code, $headers);
|
Chris@18
|
984 $cacheability = (new CacheableMetadata())->addCacheContexts([
|
Chris@18
|
985 // Make sure that different sparse fieldsets are cached differently.
|
Chris@18
|
986 'url.query_args:fields',
|
Chris@18
|
987 // Make sure that different sets of includes are cached differently.
|
Chris@18
|
988 'url.query_args:include',
|
Chris@18
|
989 ]);
|
Chris@18
|
990 $response->addCacheableDependency($cacheability);
|
Chris@18
|
991 return $response;
|
Chris@18
|
992 }
|
Chris@18
|
993
|
Chris@18
|
994 /**
|
Chris@18
|
995 * Respond with an entity collection.
|
Chris@18
|
996 *
|
Chris@18
|
997 * @param \Drupal\jsonapi\JsonApiResource\ResourceObjectData $primary_data
|
Chris@18
|
998 * The collection of entities.
|
Chris@18
|
999 * @param \Drupal\jsonapi\JsonApiResource\IncludedData|\Drupal\jsonapi\JsonApiResource\NullIncludedData $includes
|
Chris@18
|
1000 * The resources to be included in the document.
|
Chris@18
|
1001 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
1002 * The request object.
|
Chris@18
|
1003 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
1004 * The base JSON:API resource type for the request to be served.
|
Chris@18
|
1005 * @param \Drupal\jsonapi\Query\OffsetPage $page_param
|
Chris@18
|
1006 * The pagination parameter for the requested collection.
|
Chris@18
|
1007 *
|
Chris@18
|
1008 * @return \Drupal\jsonapi\ResourceResponse
|
Chris@18
|
1009 * The response.
|
Chris@18
|
1010 */
|
Chris@18
|
1011 protected function respondWithCollection(ResourceObjectData $primary_data, Data $includes, Request $request, ResourceType $resource_type, OffsetPage $page_param) {
|
Chris@18
|
1012 assert(Inspector::assertAllObjects([$includes], IncludedData::class, NullIncludedData::class));
|
Chris@18
|
1013 $link_context = [
|
Chris@18
|
1014 'has_next_page' => $primary_data->hasNextPage(),
|
Chris@18
|
1015 ];
|
Chris@18
|
1016 $meta = [];
|
Chris@18
|
1017 if ($resource_type->includeCount()) {
|
Chris@18
|
1018 $link_context['total_count'] = $meta['count'] = $primary_data->getTotalCount();
|
Chris@18
|
1019 }
|
Chris@18
|
1020 $collection_links = self::getPagerLinks($request, $page_param, $link_context);
|
Chris@18
|
1021 $response = $this->buildWrappedResponse($primary_data, $request, $includes, 200, [], $collection_links, $meta);
|
Chris@18
|
1022
|
Chris@18
|
1023 // When a new change to any entity in the resource happens, we cannot ensure
|
Chris@18
|
1024 // the validity of this cached list. Add the list tag to deal with that.
|
Chris@18
|
1025 $list_tag = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId())
|
Chris@18
|
1026 ->getListCacheTags();
|
Chris@18
|
1027 $response->getCacheableMetadata()->addCacheTags($list_tag);
|
Chris@18
|
1028 foreach ($primary_data as $entity) {
|
Chris@18
|
1029 $response->addCacheableDependency($entity);
|
Chris@18
|
1030 }
|
Chris@18
|
1031 return $response;
|
Chris@18
|
1032 }
|
Chris@18
|
1033
|
Chris@18
|
1034 /**
|
Chris@18
|
1035 * Takes a field from the origin entity and puts it to the destination entity.
|
Chris@18
|
1036 *
|
Chris@18
|
1037 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
1038 * The JSON:API resource type of the entity to be updated.
|
Chris@18
|
1039 * @param \Drupal\Core\Entity\EntityInterface $origin
|
Chris@18
|
1040 * The entity that contains the field values.
|
Chris@18
|
1041 * @param \Drupal\Core\Entity\EntityInterface $destination
|
Chris@18
|
1042 * The entity that needs to be updated.
|
Chris@18
|
1043 * @param string $field_name
|
Chris@18
|
1044 * The name of the field to extract and update.
|
Chris@18
|
1045 *
|
Chris@18
|
1046 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
Chris@18
|
1047 * Thrown when the serialized and destination entities are of different
|
Chris@18
|
1048 * types.
|
Chris@18
|
1049 */
|
Chris@18
|
1050 protected function updateEntityField(ResourceType $resource_type, EntityInterface $origin, EntityInterface $destination, $field_name) {
|
Chris@18
|
1051 // The update is different for configuration entities and content entities.
|
Chris@18
|
1052 if ($origin instanceof ContentEntityInterface && $destination instanceof ContentEntityInterface) {
|
Chris@18
|
1053 // First scenario: both are content entities.
|
Chris@18
|
1054 $field_name = $resource_type->getInternalName($field_name);
|
Chris@18
|
1055 $destination_field_list = $destination->get($field_name);
|
Chris@18
|
1056
|
Chris@18
|
1057 $origin_field_list = $origin->get($field_name);
|
Chris@18
|
1058 if ($this->checkPatchFieldAccess($destination_field_list, $origin_field_list)) {
|
Chris@18
|
1059 $destination->set($field_name, $origin_field_list->getValue());
|
Chris@18
|
1060 }
|
Chris@18
|
1061 }
|
Chris@18
|
1062 elseif ($origin instanceof ConfigEntityInterface && $destination instanceof ConfigEntityInterface) {
|
Chris@18
|
1063 // Second scenario: both are config entities.
|
Chris@18
|
1064 $destination->set($field_name, $origin->get($field_name));
|
Chris@18
|
1065 }
|
Chris@18
|
1066 else {
|
Chris@18
|
1067 throw new BadRequestHttpException('The serialized entity and the destination entity are of different types.');
|
Chris@18
|
1068 }
|
Chris@18
|
1069 }
|
Chris@18
|
1070
|
Chris@18
|
1071 /**
|
Chris@18
|
1072 * Gets includes for the given response data.
|
Chris@18
|
1073 *
|
Chris@18
|
1074 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
1075 * The request object.
|
Chris@18
|
1076 * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\ResourceObjectData $data
|
Chris@18
|
1077 * The response data from which to resolve includes.
|
Chris@18
|
1078 *
|
Chris@18
|
1079 * @return \Drupal\jsonapi\JsonApiResource\Data
|
Chris@18
|
1080 * A Data object to be included or a NullData object if the request does not
|
Chris@18
|
1081 * specify any include paths.
|
Chris@18
|
1082 *
|
Chris@18
|
1083 * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
|
Chris@18
|
1084 * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
|
Chris@18
|
1085 */
|
Chris@18
|
1086 public function getIncludes(Request $request, $data) {
|
Chris@18
|
1087 assert($data instanceof ResourceObject || $data instanceof ResourceObjectData);
|
Chris@18
|
1088 return $request->query->has('include') && ($include_parameter = $request->query->get('include')) && !empty($include_parameter)
|
Chris@18
|
1089 ? $this->includeResolver->resolve($data, $include_parameter)
|
Chris@18
|
1090 : new NullIncludedData();
|
Chris@18
|
1091 }
|
Chris@18
|
1092
|
Chris@18
|
1093 /**
|
Chris@18
|
1094 * Checks whether the given field should be PATCHed.
|
Chris@18
|
1095 *
|
Chris@18
|
1096 * @param \Drupal\Core\Field\FieldItemListInterface $original_field
|
Chris@18
|
1097 * The original (stored) value for the field.
|
Chris@18
|
1098 * @param \Drupal\Core\Field\FieldItemListInterface $received_field
|
Chris@18
|
1099 * The received value for the field.
|
Chris@18
|
1100 *
|
Chris@18
|
1101 * @return bool
|
Chris@18
|
1102 * Whether the field should be PATCHed or not.
|
Chris@18
|
1103 *
|
Chris@18
|
1104 * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
|
Chris@18
|
1105 * Thrown when the user sending the request is not allowed to update the
|
Chris@18
|
1106 * field. Only thrown when the user could not abuse this information to
|
Chris@18
|
1107 * determine the stored value.
|
Chris@18
|
1108 *
|
Chris@18
|
1109 * @internal
|
Chris@18
|
1110 *
|
Chris@18
|
1111 * @see \Drupal\rest\Plugin\rest\resource\EntityResource::checkPatchFieldAccess()
|
Chris@18
|
1112 */
|
Chris@18
|
1113 protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
|
Chris@18
|
1114 // If the user is allowed to edit the field, it is always safe to set the
|
Chris@18
|
1115 // received value. We may be setting an unchanged value, but that is ok.
|
Chris@18
|
1116 $field_edit_access = $original_field->access('edit', NULL, TRUE);
|
Chris@18
|
1117 if ($field_edit_access->isAllowed()) {
|
Chris@18
|
1118 return TRUE;
|
Chris@18
|
1119 }
|
Chris@18
|
1120
|
Chris@18
|
1121 // The user might not have access to edit the field, but still needs to
|
Chris@18
|
1122 // submit the current field value as part of the PATCH request. For
|
Chris@18
|
1123 // example, the entity keys required by denormalizers. Therefore, if the
|
Chris@18
|
1124 // received value equals the stored value, return FALSE without throwing an
|
Chris@18
|
1125 // exception. But only for fields that the user has access to view, because
|
Chris@18
|
1126 // the user has no legitimate way of knowing the current value of fields
|
Chris@18
|
1127 // that they are not allowed to view, and we must not make the presence or
|
Chris@18
|
1128 // absence of a 403 response a way to find that out.
|
Chris@18
|
1129 if ($original_field->access('view') && $original_field->equals($received_field)) {
|
Chris@18
|
1130 return FALSE;
|
Chris@18
|
1131 }
|
Chris@18
|
1132
|
Chris@18
|
1133 // It's helpful and safe to let the user know when they are not allowed to
|
Chris@18
|
1134 // update a field.
|
Chris@18
|
1135 $field_name = $received_field->getName();
|
Chris@18
|
1136 throw new EntityAccessDeniedHttpException($original_field->getEntity(), $field_edit_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name));
|
Chris@18
|
1137 }
|
Chris@18
|
1138
|
Chris@18
|
1139 /**
|
Chris@18
|
1140 * Build a collection of the entities to respond with and access objects.
|
Chris@18
|
1141 *
|
Chris@18
|
1142 * @param \Drupal\Core\Entity\EntityStorageInterface $storage
|
Chris@18
|
1143 * The entity storage to load the entities from.
|
Chris@18
|
1144 * @param int[] $ids
|
Chris@18
|
1145 * An array of entity IDs, keyed by revision ID if the entity type is
|
Chris@18
|
1146 * revisionable.
|
Chris@18
|
1147 * @param bool $load_latest_revisions
|
Chris@18
|
1148 * Whether to load the latest revisions instead of the defaults.
|
Chris@18
|
1149 *
|
Chris@18
|
1150 * @return array
|
Chris@18
|
1151 * An array of loaded entities and/or an access exceptions.
|
Chris@18
|
1152 */
|
Chris@18
|
1153 protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids, $load_latest_revisions) {
|
Chris@18
|
1154 $output = [];
|
Chris@18
|
1155 if ($load_latest_revisions) {
|
Chris@18
|
1156 assert($storage instanceof RevisionableStorageInterface);
|
Chris@18
|
1157 $entities = $storage->loadMultipleRevisions(array_keys($ids));
|
Chris@18
|
1158 }
|
Chris@18
|
1159 else {
|
Chris@18
|
1160 $entities = $storage->loadMultiple($ids);
|
Chris@18
|
1161 }
|
Chris@18
|
1162 foreach ($entities as $entity) {
|
Chris@18
|
1163 $output[$entity->id()] = $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
|
Chris@18
|
1164 }
|
Chris@18
|
1165 return array_values($output);
|
Chris@18
|
1166 }
|
Chris@18
|
1167
|
Chris@18
|
1168 /**
|
Chris@18
|
1169 * Checks if the given entity exists.
|
Chris@18
|
1170 *
|
Chris@18
|
1171 * @param \Drupal\Core\Entity\EntityInterface $entity
|
Chris@18
|
1172 * The entity for which to test existence.
|
Chris@18
|
1173 *
|
Chris@18
|
1174 * @return bool
|
Chris@18
|
1175 * Whether the entity already has been created.
|
Chris@18
|
1176 */
|
Chris@18
|
1177 protected function entityExists(EntityInterface $entity) {
|
Chris@18
|
1178 $entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
|
Chris@18
|
1179 return !empty($entity_storage->loadByProperties([
|
Chris@18
|
1180 'uuid' => $entity->uuid(),
|
Chris@18
|
1181 ]));
|
Chris@18
|
1182 }
|
Chris@18
|
1183
|
Chris@18
|
1184 /**
|
Chris@18
|
1185 * Extracts JSON:API query parameters from the request.
|
Chris@18
|
1186 *
|
Chris@18
|
1187 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
1188 * The request object.
|
Chris@18
|
1189 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
Chris@18
|
1190 * The JSON:API resource type.
|
Chris@18
|
1191 *
|
Chris@18
|
1192 * @return array
|
Chris@18
|
1193 * An array of JSON:API parameters like `sort` and `filter`.
|
Chris@18
|
1194 */
|
Chris@18
|
1195 protected function getJsonApiParams(Request $request, ResourceType $resource_type) {
|
Chris@18
|
1196 if ($request->query->has('filter')) {
|
Chris@18
|
1197 $params[Filter::KEY_NAME] = Filter::createFromQueryParameter($request->query->get('filter'), $resource_type, $this->fieldResolver);
|
Chris@18
|
1198 }
|
Chris@18
|
1199 if ($request->query->has('sort')) {
|
Chris@18
|
1200 $params[Sort::KEY_NAME] = Sort::createFromQueryParameter($request->query->get('sort'));
|
Chris@18
|
1201 }
|
Chris@18
|
1202 if ($request->query->has('page')) {
|
Chris@18
|
1203 $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter($request->query->get('page'));
|
Chris@18
|
1204 }
|
Chris@18
|
1205 else {
|
Chris@18
|
1206 $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter(['page' => ['offset' => OffsetPage::DEFAULT_OFFSET, 'limit' => OffsetPage::SIZE_MAX]]);
|
Chris@18
|
1207 }
|
Chris@18
|
1208 return $params;
|
Chris@18
|
1209 }
|
Chris@18
|
1210
|
Chris@18
|
1211 /**
|
Chris@18
|
1212 * Get the full URL for a given request object.
|
Chris@18
|
1213 *
|
Chris@18
|
1214 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
1215 * The request object.
|
Chris@18
|
1216 * @param array|null $query
|
Chris@18
|
1217 * The query parameters to use. Leave it empty to get the query from the
|
Chris@18
|
1218 * request object.
|
Chris@18
|
1219 *
|
Chris@18
|
1220 * @return \Drupal\Core\Url
|
Chris@18
|
1221 * The full URL.
|
Chris@18
|
1222 */
|
Chris@18
|
1223 protected static function getRequestLink(Request $request, $query = NULL) {
|
Chris@18
|
1224 if ($query === NULL) {
|
Chris@18
|
1225 return Url::fromUri($request->getUri());
|
Chris@18
|
1226 }
|
Chris@18
|
1227
|
Chris@18
|
1228 $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
|
Chris@18
|
1229 return Url::fromUri($uri_without_query_string)->setOption('query', $query);
|
Chris@18
|
1230 }
|
Chris@18
|
1231
|
Chris@18
|
1232 /**
|
Chris@18
|
1233 * Get the pager links for a given request object.
|
Chris@18
|
1234 *
|
Chris@18
|
1235 * @param \Symfony\Component\HttpFoundation\Request $request
|
Chris@18
|
1236 * The request object.
|
Chris@18
|
1237 * @param \Drupal\jsonapi\Query\OffsetPage $page_param
|
Chris@18
|
1238 * The current pagination parameter for the requested collection.
|
Chris@18
|
1239 * @param array $link_context
|
Chris@18
|
1240 * An associative array with extra data to build the links.
|
Chris@18
|
1241 *
|
Chris@18
|
1242 * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
Chris@18
|
1243 * An LinkCollection, with:
|
Chris@18
|
1244 * - a 'next' key if it is not the last page;
|
Chris@18
|
1245 * - 'prev' and 'first' keys if it's not the first page.
|
Chris@18
|
1246 */
|
Chris@18
|
1247 protected static function getPagerLinks(Request $request, OffsetPage $page_param, array $link_context = []) {
|
Chris@18
|
1248 $pager_links = new LinkCollection([]);
|
Chris@18
|
1249 if (!empty($link_context['total_count']) && !$total = (int) $link_context['total_count']) {
|
Chris@18
|
1250 return $pager_links;
|
Chris@18
|
1251 }
|
Chris@18
|
1252 /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
|
Chris@18
|
1253 $offset = $page_param->getOffset();
|
Chris@18
|
1254 $size = $page_param->getSize();
|
Chris@18
|
1255 if ($size <= 0) {
|
Chris@18
|
1256 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']);
|
Chris@18
|
1257 throw new CacheableBadRequestHttpException($cacheability, sprintf('The page size needs to be a positive integer.'));
|
Chris@18
|
1258 }
|
Chris@18
|
1259 $query = (array) $request->query->getIterator();
|
Chris@18
|
1260 // Check if this is not the last page.
|
Chris@18
|
1261 if ($link_context['has_next_page']) {
|
Chris@18
|
1262 $next_url = static::getRequestLink($request, static::getPagerQueries('next', $offset, $size, $query));
|
Chris@18
|
1263 $pager_links = $pager_links->withLink('next', new Link(new CacheableMetadata(), $next_url, ['next']));
|
Chris@18
|
1264
|
Chris@18
|
1265 if (!empty($total)) {
|
Chris@18
|
1266 $last_url = static::getRequestLink($request, static::getPagerQueries('last', $offset, $size, $query, $total));
|
Chris@18
|
1267 $pager_links = $pager_links->withLink('last', new Link(new CacheableMetadata(), $last_url, ['last']));
|
Chris@18
|
1268 }
|
Chris@18
|
1269 }
|
Chris@18
|
1270
|
Chris@18
|
1271 // Check if this is not the first page.
|
Chris@18
|
1272 if ($offset > 0) {
|
Chris@18
|
1273 $first_url = static::getRequestLink($request, static::getPagerQueries('first', $offset, $size, $query));
|
Chris@18
|
1274 $pager_links = $pager_links->withLink('first', new Link(new CacheableMetadata(), $first_url, ['first']));
|
Chris@18
|
1275 $prev_url = static::getRequestLink($request, static::getPagerQueries('prev', $offset, $size, $query));
|
Chris@18
|
1276 $pager_links = $pager_links->withLink('prev', new Link(new CacheableMetadata(), $prev_url, ['prev']));
|
Chris@18
|
1277 }
|
Chris@18
|
1278
|
Chris@18
|
1279 return $pager_links;
|
Chris@18
|
1280 }
|
Chris@18
|
1281
|
Chris@18
|
1282 /**
|
Chris@18
|
1283 * Get the query param array.
|
Chris@18
|
1284 *
|
Chris@18
|
1285 * @param string $link_id
|
Chris@18
|
1286 * The name of the pagination link requested.
|
Chris@18
|
1287 * @param int $offset
|
Chris@18
|
1288 * The starting index.
|
Chris@18
|
1289 * @param int $size
|
Chris@18
|
1290 * The pagination page size.
|
Chris@18
|
1291 * @param array $query
|
Chris@18
|
1292 * The query parameters.
|
Chris@18
|
1293 * @param int $total
|
Chris@18
|
1294 * The total size of the collection.
|
Chris@18
|
1295 *
|
Chris@18
|
1296 * @return array
|
Chris@18
|
1297 * The pagination query param array.
|
Chris@18
|
1298 */
|
Chris@18
|
1299 protected static function getPagerQueries($link_id, $offset, $size, array $query = [], $total = 0) {
|
Chris@18
|
1300 $extra_query = [];
|
Chris@18
|
1301 switch ($link_id) {
|
Chris@18
|
1302 case 'next':
|
Chris@18
|
1303 $extra_query = [
|
Chris@18
|
1304 'page' => [
|
Chris@18
|
1305 'offset' => $offset + $size,
|
Chris@18
|
1306 'limit' => $size,
|
Chris@18
|
1307 ],
|
Chris@18
|
1308 ];
|
Chris@18
|
1309 break;
|
Chris@18
|
1310
|
Chris@18
|
1311 case 'first':
|
Chris@18
|
1312 $extra_query = [
|
Chris@18
|
1313 'page' => [
|
Chris@18
|
1314 'offset' => 0,
|
Chris@18
|
1315 'limit' => $size,
|
Chris@18
|
1316 ],
|
Chris@18
|
1317 ];
|
Chris@18
|
1318 break;
|
Chris@18
|
1319
|
Chris@18
|
1320 case 'last':
|
Chris@18
|
1321 if ($total) {
|
Chris@18
|
1322 $extra_query = [
|
Chris@18
|
1323 'page' => [
|
Chris@18
|
1324 'offset' => (ceil($total / $size) - 1) * $size,
|
Chris@18
|
1325 'limit' => $size,
|
Chris@18
|
1326 ],
|
Chris@18
|
1327 ];
|
Chris@18
|
1328 }
|
Chris@18
|
1329 break;
|
Chris@18
|
1330
|
Chris@18
|
1331 case 'prev':
|
Chris@18
|
1332 $extra_query = [
|
Chris@18
|
1333 'page' => [
|
Chris@18
|
1334 'offset' => max($offset - $size, 0),
|
Chris@18
|
1335 'limit' => $size,
|
Chris@18
|
1336 ],
|
Chris@18
|
1337 ];
|
Chris@18
|
1338 break;
|
Chris@18
|
1339 }
|
Chris@18
|
1340 return array_merge($query, $extra_query);
|
Chris@18
|
1341 }
|
Chris@18
|
1342
|
Chris@18
|
1343 }
|