annotate core/modules/jsonapi/src/Controller/EntityResource.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents af1871eacc83
children
rev   line source
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 }