annotate core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @ 5:12f9dff5fda9 tip

Update to Drupal core 8.7.1
author Chris Cannam
date Thu, 09 May 2019 15:34:47 +0100
parents
children
rev   line source
Chris@5 1 <?php
Chris@5 2
Chris@5 3 namespace Drupal\Tests\jsonapi\Functional;
Chris@5 4
Chris@5 5 use Drupal\Component\Serialization\Json;
Chris@5 6 use Drupal\Component\Utility\NestedArray;
Chris@5 7 use Drupal\Component\Utility\Random;
Chris@5 8 use Drupal\Core\Access\AccessResult;
Chris@5 9 use Drupal\Core\Access\AccessResultReasonInterface;
Chris@5 10 use Drupal\Core\Cache\Cache;
Chris@5 11 use Drupal\Core\Cache\CacheableMetadata;
Chris@5 12 use Drupal\Core\Cache\CacheableResponseInterface;
Chris@5 13 use Drupal\Core\Config\Entity\ConfigEntityInterface;
Chris@5 14 use Drupal\Core\Entity\ContentEntityInterface;
Chris@5 15 use Drupal\Core\Entity\ContentEntityNullStorage;
Chris@5 16 use Drupal\Core\Entity\EntityInterface;
Chris@5 17 use Drupal\Core\Entity\EntityPublishedInterface;
Chris@5 18 use Drupal\Core\Entity\FieldableEntityInterface;
Chris@5 19 use Drupal\Core\Entity\RevisionableInterface;
Chris@5 20 use Drupal\Core\Entity\RevisionLogInterface;
Chris@5 21 use Drupal\Core\Field\FieldDefinitionInterface;
Chris@5 22 use Drupal\Core\Field\FieldStorageDefinitionInterface;
Chris@5 23 use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
Chris@5 24 use Drupal\Core\Session\AccountInterface;
Chris@5 25 use Drupal\Core\TypedData\DataReferenceTargetDefinition;
Chris@5 26 use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
Chris@5 27 use Drupal\Core\Url;
Chris@5 28 use Drupal\field\Entity\FieldConfig;
Chris@5 29 use Drupal\field\Entity\FieldStorageConfig;
Chris@5 30 use Drupal\jsonapi\JsonApiResource\LinkCollection;
Chris@5 31 use Drupal\jsonapi\JsonApiResource\NullIncludedData;
Chris@5 32 use Drupal\jsonapi\JsonApiResource\Link;
Chris@5 33 use Drupal\jsonapi\JsonApiResource\ResourceObject;
Chris@5 34 use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
Chris@5 35 use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
Chris@5 36 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
Chris@5 37 use Drupal\jsonapi\ResourceResponse;
Chris@5 38 use Drupal\path\Plugin\Field\FieldType\PathItem;
Chris@5 39 use Drupal\Tests\BrowserTestBase;
Chris@5 40 use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
Chris@5 41 use Drupal\user\Entity\Role;
Chris@5 42 use Drupal\user\EntityOwnerInterface;
Chris@5 43 use Drupal\user\RoleInterface;
Chris@5 44 use Drupal\user\UserInterface;
Chris@5 45 use GuzzleHttp\RequestOptions;
Chris@5 46 use Psr\Http\Message\ResponseInterface;
Chris@5 47 use Symfony\Component\HttpFoundation\Response;
Chris@5 48
Chris@5 49 /**
Chris@5 50 * Subclass this for every JSON:API resource type.
Chris@5 51 */
Chris@5 52 abstract class ResourceTestBase extends BrowserTestBase {
Chris@5 53
Chris@5 54 use ResourceResponseTestTrait;
Chris@5 55 use ContentModerationTestTrait;
Chris@5 56 use JsonApiRequestTestTrait;
Chris@5 57
Chris@5 58 /**
Chris@5 59 * {@inheritdoc}
Chris@5 60 */
Chris@5 61 protected static $modules = [
Chris@5 62 'jsonapi',
Chris@5 63 'basic_auth',
Chris@5 64 'rest_test',
Chris@5 65 'jsonapi_test_field_access',
Chris@5 66 'text',
Chris@5 67 ];
Chris@5 68
Chris@5 69 /**
Chris@5 70 * The tested entity type.
Chris@5 71 *
Chris@5 72 * @var string
Chris@5 73 */
Chris@5 74 protected static $entityTypeId = NULL;
Chris@5 75
Chris@5 76 /**
Chris@5 77 * The name of the tested JSON:API resource type.
Chris@5 78 *
Chris@5 79 * @var string
Chris@5 80 */
Chris@5 81 protected static $resourceTypeName = NULL;
Chris@5 82
Chris@5 83 /**
Chris@5 84 * Whether the tested JSON:API resource is versionable.
Chris@5 85 *
Chris@5 86 * @var bool
Chris@5 87 */
Chris@5 88 protected static $resourceTypeIsVersionable = FALSE;
Chris@5 89
Chris@5 90 /**
Chris@5 91 * The JSON:API resource type for the tested entity type plus bundle.
Chris@5 92 *
Chris@5 93 * Necessary for looking up public (alias) or internal (actual) field names.
Chris@5 94 *
Chris@5 95 * @var \Drupal\jsonapi\ResourceType\ResourceType
Chris@5 96 */
Chris@5 97 protected $resourceType;
Chris@5 98
Chris@5 99 /**
Chris@5 100 * The fields that are protected against modification during PATCH requests.
Chris@5 101 *
Chris@5 102 * @var string[]
Chris@5 103 */
Chris@5 104 protected static $patchProtectedFieldNames;
Chris@5 105
Chris@5 106 /**
Chris@5 107 * Fields that need unique values.
Chris@5 108 *
Chris@5 109 * @var string[]
Chris@5 110 *
Chris@5 111 * @see ::testPostIndividual()
Chris@5 112 * @see ::getModifiedEntityForPostTesting()
Chris@5 113 */
Chris@5 114 protected static $uniqueFieldNames = [];
Chris@5 115
Chris@5 116 /**
Chris@5 117 * The entity ID for the first created entity in testPost().
Chris@5 118 *
Chris@5 119 * The default value of 2 should work for most content entities.
Chris@5 120 *
Chris@5 121 * @var string|int
Chris@5 122 *
Chris@5 123 * @see ::testPostIndividual()
Chris@5 124 */
Chris@5 125 protected static $firstCreatedEntityId = 2;
Chris@5 126
Chris@5 127 /**
Chris@5 128 * The entity ID for the second created entity in testPost().
Chris@5 129 *
Chris@5 130 * The default value of 3 should work for most content entities.
Chris@5 131 *
Chris@5 132 * @var string|int
Chris@5 133 *
Chris@5 134 * @see ::testPostIndividual()
Chris@5 135 */
Chris@5 136 protected static $secondCreatedEntityId = 3;
Chris@5 137
Chris@5 138 /**
Chris@5 139 * Optionally specify which field is the 'label' field.
Chris@5 140 *
Chris@5 141 * Some entities specify a 'label_callback', but not a 'label' entity key.
Chris@5 142 * For example: User.
Chris@5 143 *
Chris@5 144 * @var string|null
Chris@5 145 *
Chris@5 146 * @see ::getInvalidNormalizedEntityToCreate()
Chris@5 147 */
Chris@5 148 protected static $labelFieldName = NULL;
Chris@5 149
Chris@5 150 /**
Chris@5 151 * Whether new revisions of updated entities should be created by default.
Chris@5 152 *
Chris@5 153 * @var bool
Chris@5 154 */
Chris@5 155 protected static $newRevisionsShouldBeAutomatic = FALSE;
Chris@5 156
Chris@5 157 /**
Chris@5 158 * Whether anonymous users can view labels of this resource type.
Chris@5 159 *
Chris@5 160 * @var bool
Chris@5 161 */
Chris@5 162 protected static $anonymousUsersCanViewLabels = FALSE;
Chris@5 163
Chris@5 164 /**
Chris@5 165 * The standard `jsonapi` top-level document member.
Chris@5 166 *
Chris@5 167 * @var array
Chris@5 168 */
Chris@5 169 protected static $jsonApiMember = [
Chris@5 170 'version' => '1.0',
Chris@5 171 'meta' => [
Chris@5 172 'links' => ['self' => ['href' => 'http://jsonapi.org/format/1.0/']],
Chris@5 173 ],
Chris@5 174 ];
Chris@5 175
Chris@5 176 /**
Chris@5 177 * The entity being tested.
Chris@5 178 *
Chris@5 179 * @var \Drupal\Core\Entity\EntityInterface
Chris@5 180 */
Chris@5 181 protected $entity;
Chris@5 182
Chris@5 183 /**
Chris@5 184 * Another entity of the same type used for testing.
Chris@5 185 *
Chris@5 186 * @var \Drupal\Core\Entity\EntityInterface
Chris@5 187 */
Chris@5 188 protected $anotherEntity;
Chris@5 189
Chris@5 190 /**
Chris@5 191 * The account to use for authentication.
Chris@5 192 *
Chris@5 193 * @var null|\Drupal\Core\Session\AccountInterface
Chris@5 194 */
Chris@5 195 protected $account;
Chris@5 196
Chris@5 197 /**
Chris@5 198 * The entity storage.
Chris@5 199 *
Chris@5 200 * @var \Drupal\Core\Entity\EntityStorageInterface
Chris@5 201 */
Chris@5 202 protected $entityStorage;
Chris@5 203
Chris@5 204 /**
Chris@5 205 * The UUID key.
Chris@5 206 *
Chris@5 207 * @var string
Chris@5 208 */
Chris@5 209 protected $uuidKey;
Chris@5 210
Chris@5 211 /**
Chris@5 212 * The serializer service.
Chris@5 213 *
Chris@5 214 * @var \Symfony\Component\Serializer\Serializer
Chris@5 215 */
Chris@5 216 protected $serializer;
Chris@5 217
Chris@5 218 /**
Chris@5 219 * {@inheritdoc}
Chris@5 220 */
Chris@5 221 public function setUp() {
Chris@5 222 parent::setUp();
Chris@5 223
Chris@5 224 $this->serializer = $this->container->get('jsonapi.serializer');
Chris@5 225
Chris@5 226 // Ensure the anonymous user role has no permissions at all.
Chris@5 227 $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
Chris@5 228 foreach ($user_role->getPermissions() as $permission) {
Chris@5 229 $user_role->revokePermission($permission);
Chris@5 230 }
Chris@5 231 $user_role->save();
Chris@5 232 assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
Chris@5 233
Chris@5 234 // Ensure the authenticated user role has no permissions at all.
Chris@5 235 $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
Chris@5 236 foreach ($user_role->getPermissions() as $permission) {
Chris@5 237 $user_role->revokePermission($permission);
Chris@5 238 }
Chris@5 239 $user_role->save();
Chris@5 240 assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
Chris@5 241
Chris@5 242 // Create an account, which tests will use. Also ensure the @current_user
Chris@5 243 // service this account, to ensure certain access check logic in tests works
Chris@5 244 // as expected.
Chris@5 245 $this->account = $this->createUser();
Chris@5 246 $this->container->get('current_user')->setAccount($this->account);
Chris@5 247
Chris@5 248 // Create an entity.
Chris@5 249 $entity_type_manager = $this->container->get('entity_type.manager');
Chris@5 250 $this->entityStorage = $entity_type_manager->getStorage(static::$entityTypeId);
Chris@5 251 $this->uuidKey = $entity_type_manager->getDefinition(static::$entityTypeId)
Chris@5 252 ->getKey('uuid');
Chris@5 253 $this->entity = $this->setUpFields($this->createEntity(), $this->account);
Chris@5 254
Chris@5 255 $this->resourceType = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
Chris@5 256 }
Chris@5 257
Chris@5 258 /**
Chris@5 259 * Sets up additional fields for testing.
Chris@5 260 *
Chris@5 261 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 262 * The primary test entity.
Chris@5 263 * @param \Drupal\user\UserInterface $account
Chris@5 264 * The primary test user account.
Chris@5 265 *
Chris@5 266 * @return \Drupal\Core\Entity\EntityInterface
Chris@5 267 * The reloaded entity with the new fields attached.
Chris@5 268 *
Chris@5 269 * @throws \Drupal\Core\Entity\EntityStorageException
Chris@5 270 */
Chris@5 271 protected function setUpFields(EntityInterface $entity, UserInterface $account) {
Chris@5 272 if (!$entity instanceof FieldableEntityInterface) {
Chris@5 273 return $entity;
Chris@5 274 }
Chris@5 275
Chris@5 276 $entity_bundle = $entity->bundle();
Chris@5 277 $account_bundle = $account->bundle();
Chris@5 278
Chris@5 279 // Add access-protected field.
Chris@5 280 FieldStorageConfig::create([
Chris@5 281 'entity_type' => static::$entityTypeId,
Chris@5 282 'field_name' => 'field_rest_test',
Chris@5 283 'type' => 'text',
Chris@5 284 ])
Chris@5 285 ->setCardinality(1)
Chris@5 286 ->save();
Chris@5 287 FieldConfig::create([
Chris@5 288 'entity_type' => static::$entityTypeId,
Chris@5 289 'field_name' => 'field_rest_test',
Chris@5 290 'bundle' => $entity_bundle,
Chris@5 291 ])
Chris@5 292 ->setLabel('Test field')
Chris@5 293 ->setTranslatable(FALSE)
Chris@5 294 ->save();
Chris@5 295
Chris@5 296 FieldStorageConfig::create([
Chris@5 297 'entity_type' => static::$entityTypeId,
Chris@5 298 'field_name' => 'field_jsonapi_test_entity_ref',
Chris@5 299 'type' => 'entity_reference',
Chris@5 300 ])
Chris@5 301 ->setSetting('target_type', 'user')
Chris@5 302 ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED)
Chris@5 303 ->save();
Chris@5 304
Chris@5 305 FieldConfig::create([
Chris@5 306 'entity_type' => static::$entityTypeId,
Chris@5 307 'field_name' => 'field_jsonapi_test_entity_ref',
Chris@5 308 'bundle' => $entity_bundle,
Chris@5 309 ])
Chris@5 310 ->setTranslatable(FALSE)
Chris@5 311 ->setSetting('handler', 'default')
Chris@5 312 ->setSetting('handler_settings', [
Chris@5 313 'target_bundles' => NULL,
Chris@5 314 ])
Chris@5 315 ->save();
Chris@5 316
Chris@5 317 // Add multi-value field.
Chris@5 318 FieldStorageConfig::create([
Chris@5 319 'entity_type' => static::$entityTypeId,
Chris@5 320 'field_name' => 'field_rest_test_multivalue',
Chris@5 321 'type' => 'string',
Chris@5 322 ])
Chris@5 323 ->setCardinality(3)
Chris@5 324 ->save();
Chris@5 325 FieldConfig::create([
Chris@5 326 'entity_type' => static::$entityTypeId,
Chris@5 327 'field_name' => 'field_rest_test_multivalue',
Chris@5 328 'bundle' => $entity_bundle,
Chris@5 329 ])
Chris@5 330 ->setLabel('Test field: multi-value')
Chris@5 331 ->setTranslatable(FALSE)
Chris@5 332 ->save();
Chris@5 333
Chris@5 334 \Drupal::service('router.builder')->rebuildIfNeeded();
Chris@5 335
Chris@5 336 // Reload entity so that it has the new field.
Chris@5 337 $reloaded_entity = $this->entityLoadUnchanged($entity->id());
Chris@5 338 // Some entity types are not stored, hence they cannot be reloaded.
Chris@5 339 if ($reloaded_entity !== NULL) {
Chris@5 340 $entity = $reloaded_entity;
Chris@5 341
Chris@5 342 // Set a default value on the fields.
Chris@5 343 $entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
Chris@5 344 $entity->set('field_jsonapi_test_entity_ref', ['user' => $account->id()]);
Chris@5 345 $entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
Chris@5 346 $entity->save();
Chris@5 347 }
Chris@5 348
Chris@5 349 return $entity;
Chris@5 350 }
Chris@5 351
Chris@5 352 /**
Chris@5 353 * Sets up a collection of entities of the same type for testing.
Chris@5 354 *
Chris@5 355 * @return \Drupal\Core\Entity\EntityInterface[]
Chris@5 356 * The collection of entities to test.
Chris@5 357 *
Chris@5 358 * @throws \Drupal\Core\Entity\EntityStorageException
Chris@5 359 */
Chris@5 360 protected function getData() {
Chris@5 361 if ($this->entityStorage->getQuery()->count()->execute() < 2) {
Chris@5 362 $this->createAnotherEntity('two');
Chris@5 363 }
Chris@5 364 $query = $this->entityStorage->getQuery()->sort($this->entity->getEntityType()->getKey('id'));
Chris@5 365 return $this->entityStorage->loadMultiple($query->execute());
Chris@5 366 }
Chris@5 367
Chris@5 368 /**
Chris@5 369 * Generates a JSON:API normalization for the given entity.
Chris@5 370 *
Chris@5 371 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 372 * The entity to generate a JSON:API normalization for.
Chris@5 373 * @param \Drupal\Core\Url $url
Chris@5 374 * The URL to use as the "self" link.
Chris@5 375 *
Chris@5 376 * @return array
Chris@5 377 * The JSON:API normalization for the given entity.
Chris@5 378 */
Chris@5 379 protected function normalize(EntityInterface $entity, Url $url) {
Chris@5 380 $self_link = new Link(new CacheableMetadata(), $url, ['self']);
Chris@5 381 $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
Chris@5 382 $doc = new JsonApiDocumentTopLevel(new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1), new NullIncludedData(), new LinkCollection(['self' => $self_link]));
Chris@5 383 return $this->serializer->normalize($doc, 'api_json', [
Chris@5 384 'resource_type' => $resource_type,
Chris@5 385 'account' => $this->account,
Chris@5 386 ])->getNormalization();
Chris@5 387 }
Chris@5 388
Chris@5 389 /**
Chris@5 390 * Creates the entity to be tested.
Chris@5 391 *
Chris@5 392 * @return \Drupal\Core\Entity\EntityInterface
Chris@5 393 * The entity to be tested.
Chris@5 394 */
Chris@5 395 abstract protected function createEntity();
Chris@5 396
Chris@5 397 /**
Chris@5 398 * Creates another entity to be tested.
Chris@5 399 *
Chris@5 400 * @param mixed $key
Chris@5 401 * A unique key to be used for the ID and/or label of the duplicated entity.
Chris@5 402 *
Chris@5 403 * @return \Drupal\Core\Entity\EntityInterface
Chris@5 404 * Another entity based on $this->entity.
Chris@5 405 *
Chris@5 406 * @throws \Drupal\Core\Entity\EntityStorageException
Chris@5 407 */
Chris@5 408 protected function createAnotherEntity($key) {
Chris@5 409 $duplicate = $this->getEntityDuplicate($this->entity, $key);
Chris@5 410 // Some entity types are not stored, hence they cannot be reloaded.
Chris@5 411 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
Chris@5 412 $duplicate->set('field_rest_test', 'Second collection entity');
Chris@5 413 }
Chris@5 414 $duplicate->save();
Chris@5 415 return $duplicate;
Chris@5 416 }
Chris@5 417
Chris@5 418 /**
Chris@5 419 * {@inheritdoc}
Chris@5 420 */
Chris@5 421 protected function getEntityDuplicate(EntityInterface $original, $key) {
Chris@5 422 $duplicate = $original->createDuplicate();
Chris@5 423 if ($label_key = $original->getEntityType()->getKey('label')) {
Chris@5 424 $duplicate->set($label_key, $original->label() . '_' . $key);
Chris@5 425 }
Chris@5 426 if ($duplicate instanceof ConfigEntityInterface && $id_key = $duplicate->getEntityType()->getKey('id')) {
Chris@5 427 $id = $original->id();
Chris@5 428 $id_key = $duplicate->getEntityType()->getKey('id');
Chris@5 429 $duplicate->set($id_key, $id . '_' . $key);
Chris@5 430 }
Chris@5 431 return $duplicate;
Chris@5 432 }
Chris@5 433
Chris@5 434 /**
Chris@5 435 * Returns the expected JSON:API document for the entity.
Chris@5 436 *
Chris@5 437 * @see ::createEntity()
Chris@5 438 *
Chris@5 439 * @return array
Chris@5 440 * A JSON:API response document.
Chris@5 441 */
Chris@5 442 abstract protected function getExpectedDocument();
Chris@5 443
Chris@5 444 /**
Chris@5 445 * Returns the JSON:API POST document.
Chris@5 446 *
Chris@5 447 * @see ::testPostIndividual()
Chris@5 448 *
Chris@5 449 * @return array
Chris@5 450 * A JSON:API request document.
Chris@5 451 */
Chris@5 452 abstract protected function getPostDocument();
Chris@5 453
Chris@5 454 /**
Chris@5 455 * Returns the JSON:API PATCH document.
Chris@5 456 *
Chris@5 457 * By default, reuses ::getPostDocument(), which works fine for most entity
Chris@5 458 * types. A counter example: the 'comment' entity type.
Chris@5 459 *
Chris@5 460 * @see ::testPatchIndividual()
Chris@5 461 *
Chris@5 462 * @return array
Chris@5 463 * A JSON:API request document.
Chris@5 464 */
Chris@5 465 protected function getPatchDocument() {
Chris@5 466 return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getPostDocument());
Chris@5 467 }
Chris@5 468
Chris@5 469 /**
Chris@5 470 * Returns the expected cacheability for an unauthorized response.
Chris@5 471 *
Chris@5 472 * @return \Drupal\Core\Cache\CacheableMetadata
Chris@5 473 * The expected cacheability.
Chris@5 474 */
Chris@5 475 protected function getExpectedUnauthorizedAccessCacheability() {
Chris@5 476 return (new CacheableMetadata())
Chris@5 477 ->setCacheTags(['4xx-response', 'http_response'])
Chris@5 478 ->setCacheContexts(['url.site', 'user.permissions'])
Chris@5 479 ->addCacheContexts($this->entity->getEntityType()->isRevisionable()
Chris@5 480 ? ['url.query_args:resourceVersion']
Chris@5 481 : []
Chris@5 482 );
Chris@5 483 }
Chris@5 484
Chris@5 485 /**
Chris@5 486 * The expected cache tags for the GET/HEAD response of the test entity.
Chris@5 487 *
Chris@5 488 * @param array|null $sparse_fieldset
Chris@5 489 * If a sparse fieldset is being requested, limit the expected cache tags
Chris@5 490 * for this entity's fields to just these fields.
Chris@5 491 *
Chris@5 492 * @return string[]
Chris@5 493 * A set of cache tags.
Chris@5 494 *
Chris@5 495 * @see ::testGetIndividual()
Chris@5 496 */
Chris@5 497 protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
Chris@5 498 $expected_cache_tags = [
Chris@5 499 'http_response',
Chris@5 500 ];
Chris@5 501 return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
Chris@5 502 }
Chris@5 503
Chris@5 504 /**
Chris@5 505 * The expected cache contexts for the GET/HEAD response of the test entity.
Chris@5 506 *
Chris@5 507 * @param array|null $sparse_fieldset
Chris@5 508 * If a sparse fieldset is being requested, limit the expected cache
Chris@5 509 * contexts for this entity's fields to just these fields.
Chris@5 510 *
Chris@5 511 * @return string[]
Chris@5 512 * A set of cache contexts.
Chris@5 513 *
Chris@5 514 * @see ::testGetIndividual()
Chris@5 515 */
Chris@5 516 protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
Chris@5 517 $cache_contexts = [
Chris@5 518 // Cache contexts for JSON:API URL query parameters.
Chris@5 519 'url.query_args:fields',
Chris@5 520 'url.query_args:include',
Chris@5 521 // Drupal defaults.
Chris@5 522 'url.site',
Chris@5 523 'user.permissions',
Chris@5 524 ];
Chris@5 525 $entity_type = $this->entity->getEntityType();
Chris@5 526 return Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@5 527 }
Chris@5 528
Chris@5 529 /**
Chris@5 530 * Computes the cacheability for a given entity collection.
Chris@5 531 *
Chris@5 532 * @param \Drupal\Core\Session\AccountInterface $account
Chris@5 533 * An account for which cacheability should be computed (cacheability is
Chris@5 534 * dependent on access).
Chris@5 535 * @param \Drupal\Core\Entity\EntityInterface[] $collection
Chris@5 536 * The entities for which cacheability should be computed.
Chris@5 537 * @param array $sparse_fieldset
Chris@5 538 * (optional) If a sparse fieldset is being requested, limit the expected
Chris@5 539 * cacheability for the collection entities' fields to just those in the
Chris@5 540 * fieldset. NULL means all fields.
Chris@5 541 * @param bool $filtered
Chris@5 542 * Whether the collection is filtered or not.
Chris@5 543 *
Chris@5 544 * @return \Drupal\Core\Cache\CacheableMetadata
Chris@5 545 * The expected cacheability for the given entity collection.
Chris@5 546 */
Chris@5 547 protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
Chris@5 548 $cacheability = array_reduce($collection, function (CacheableMetadata $cacheability, EntityInterface $entity) use ($sparse_fieldset, $account) {
Chris@5 549 $access_result = static::entityAccess($entity, 'view', $account);
Chris@5 550 if (!$access_result->isAllowed()) {
Chris@5 551 $access_result = static::entityAccess($entity, 'view label', $account)->addCacheableDependency($access_result);
Chris@5 552 }
Chris@5 553 $cacheability->addCacheableDependency($access_result);
Chris@5 554 if ($access_result->isAllowed()) {
Chris@5 555 $cacheability->addCacheableDependency($entity);
Chris@5 556 if ($entity instanceof FieldableEntityInterface) {
Chris@5 557 foreach ($entity as $field_name => $field_item_list) {
Chris@5 558 /* @var \Drupal\Core\Field\FieldItemListInterface $field_item_list */
Chris@5 559 if (is_null($sparse_fieldset) || in_array($field_name, $sparse_fieldset)) {
Chris@5 560 $field_access = static::entityFieldAccess($entity, $field_name, 'view', $account);
Chris@5 561 $cacheability->addCacheableDependency($field_access);
Chris@5 562 if ($field_access->isAllowed()) {
Chris@5 563 foreach ($field_item_list as $field_item) {
Chris@5 564 /* @var \Drupal\Core\Field\FieldItemInterface $field_item */
Chris@5 565 foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property) {
Chris@5 566 $cacheability->addCacheableDependency(CacheableMetadata::createFromObject($property));
Chris@5 567 }
Chris@5 568 }
Chris@5 569 }
Chris@5 570 }
Chris@5 571 }
Chris@5 572 }
Chris@5 573 }
Chris@5 574 return $cacheability;
Chris@5 575 }, new CacheableMetadata());
Chris@5 576 $entity_type = reset($collection)->getEntityType();
Chris@5 577 $cacheability->addCacheTags(['http_response']);
Chris@5 578 $cacheability->addCacheTags($entity_type->getListCacheTags());
Chris@5 579 $cache_contexts = [
Chris@5 580 // Cache contexts for JSON:API URL query parameters.
Chris@5 581 'url.query_args:fields',
Chris@5 582 'url.query_args:filter',
Chris@5 583 'url.query_args:include',
Chris@5 584 'url.query_args:page',
Chris@5 585 'url.query_args:sort',
Chris@5 586 // Drupal defaults.
Chris@5 587 'url.site',
Chris@5 588 ];
Chris@5 589 // If the entity type is revisionable, add a resource version cache context.
Chris@5 590 $cache_contexts = Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@5 591 $cacheability->addCacheContexts($cache_contexts);
Chris@5 592 return $cacheability;
Chris@5 593 }
Chris@5 594
Chris@5 595 /**
Chris@5 596 * Sets up the necessary authorization.
Chris@5 597 *
Chris@5 598 * In case of a test verifying publicly accessible REST resources: grant
Chris@5 599 * permissions to the anonymous user role.
Chris@5 600 *
Chris@5 601 * In case of a test verifying behavior when using a particular authentication
Chris@5 602 * provider: create a user with a particular set of permissions.
Chris@5 603 *
Chris@5 604 * Because of the $method parameter, it's possible to first set up
Chris@5 605 * authentication for only GET, then add POST, et cetera. This then also
Chris@5 606 * allows for verifying a 403 in case of missing authorization.
Chris@5 607 *
Chris@5 608 * @param string $method
Chris@5 609 * The HTTP method for which to set up authentication.
Chris@5 610 *
Chris@5 611 * @see ::grantPermissionsToAnonymousRole()
Chris@5 612 * @see ::grantPermissionsToAuthenticatedRole()
Chris@5 613 */
Chris@5 614 abstract protected function setUpAuthorization($method);
Chris@5 615
Chris@5 616 /**
Chris@5 617 * Sets up the necessary authorization for handling revisions.
Chris@5 618 *
Chris@5 619 * @param string $method
Chris@5 620 * The HTTP method for which to set up authentication.
Chris@5 621 *
Chris@5 622 * @see ::testRevisions()
Chris@5 623 */
Chris@5 624 protected function setUpRevisionAuthorization($method) {
Chris@5 625 assert($method === 'GET', 'Only read operations on revisions are supported.');
Chris@5 626 $this->setUpAuthorization($method);
Chris@5 627 }
Chris@5 628
Chris@5 629 /**
Chris@5 630 * Return the expected error message.
Chris@5 631 *
Chris@5 632 * @param string $method
Chris@5 633 * The HTTP method (GET, POST, PATCH, DELETE).
Chris@5 634 *
Chris@5 635 * @return string
Chris@5 636 * The error string.
Chris@5 637 */
Chris@5 638 protected function getExpectedUnauthorizedAccessMessage($method) {
Chris@5 639 $permission = $this->entity->getEntityType()->getAdminPermission();
Chris@5 640 if ($permission !== FALSE) {
Chris@5 641 return "The '{$permission}' permission is required.";
Chris@5 642 }
Chris@5 643
Chris@5 644 return NULL;
Chris@5 645 }
Chris@5 646
Chris@5 647 /**
Chris@5 648 * Grants permissions to the authenticated role.
Chris@5 649 *
Chris@5 650 * @param string[] $permissions
Chris@5 651 * Permissions to grant.
Chris@5 652 */
Chris@5 653 protected function grantPermissionsToTestedRole(array $permissions) {
Chris@5 654 $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
Chris@5 655 }
Chris@5 656
Chris@5 657 /**
Chris@5 658 * Revokes permissions from the authenticated role.
Chris@5 659 *
Chris@5 660 * @param string[] $permissions
Chris@5 661 * Permissions to revoke.
Chris@5 662 */
Chris@5 663 protected function revokePermissionsFromTestedRole(array $permissions) {
Chris@5 664 $role = Role::load(RoleInterface::AUTHENTICATED_ID);
Chris@5 665 foreach ($permissions as $permission) {
Chris@5 666 $role->revokePermission($permission);
Chris@5 667 }
Chris@5 668 $role->trustData()->save();
Chris@5 669 }
Chris@5 670
Chris@5 671 /**
Chris@5 672 * Asserts that a resource response has the given status code and body.
Chris@5 673 *
Chris@5 674 * @param int $expected_status_code
Chris@5 675 * The expected response status.
Chris@5 676 * @param array|null|false $expected_document
Chris@5 677 * The expected document or NULL if there should not be a response body.
Chris@5 678 * FALSE in case this should not be asserted.
Chris@5 679 * @param \Psr\Http\Message\ResponseInterface $response
Chris@5 680 * The response to assert.
Chris@5 681 * @param string[]|false $expected_cache_tags
Chris@5 682 * (optional) The expected cache tags in the X-Drupal-Cache-Tags response
Chris@5 683 * header, or FALSE if that header should be absent. Defaults to FALSE.
Chris@5 684 * @param string[]|false $expected_cache_contexts
Chris@5 685 * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
Chris@5 686 * response header, or FALSE if that header should be absent. Defaults to
Chris@5 687 * FALSE.
Chris@5 688 * @param string|false $expected_page_cache_header_value
Chris@5 689 * (optional) The expected X-Drupal-Cache response header value, or FALSE if
Chris@5 690 * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
Chris@5 691 * to FALSE.
Chris@5 692 * @param string|false $expected_dynamic_page_cache_header_value
Chris@5 693 * (optional) The expected X-Drupal-Dynamic-Cache response header value, or
Chris@5 694 * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
Chris@5 695 * Defaults to FALSE.
Chris@5 696 */
Chris@5 697 protected function assertResourceResponse($expected_status_code, $expected_document, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
Chris@5 698 $this->assertSame($expected_status_code, $response->getStatusCode(), var_export(Json::decode((string) $response->getBody()), TRUE));
Chris@5 699 if ($expected_status_code === 204) {
Chris@5 700 // DELETE responses should not include a Content-Type header. But Apache
Chris@5 701 // sets it to 'text/html' by default. We also cannot detect the presence
Chris@5 702 // of Apache either here in the CLI. For now having this documented here
Chris@5 703 // is all we can do.
Chris@5 704 /* $this->assertSame(FALSE, $response->hasHeader('Content-Type')); */
Chris@5 705 $this->assertSame('', (string) $response->getBody());
Chris@5 706 }
Chris@5 707 else {
Chris@5 708 $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type'));
Chris@5 709 if ($expected_document !== FALSE) {
Chris@5 710 $response_document = Json::decode((string) $response->getBody());
Chris@5 711 if ($expected_document === NULL) {
Chris@5 712 $this->assertNull($response_document);
Chris@5 713 }
Chris@5 714 else {
Chris@5 715 $this->assertSameDocument($expected_document, $response_document);
Chris@5 716 }
Chris@5 717 }
Chris@5 718 }
Chris@5 719
Chris@5 720 // Expected cache tags: X-Drupal-Cache-Tags header.
Chris@5 721 $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
Chris@5 722 if (is_array($expected_cache_tags)) {
Chris@5 723 $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
Chris@5 724 }
Chris@5 725
Chris@5 726 // Expected cache contexts: X-Drupal-Cache-Contexts header.
Chris@5 727 $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
Chris@5 728 if (is_array($expected_cache_contexts)) {
Chris@5 729 $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
Chris@5 730 $this->assertSame($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
Chris@5 731 }
Chris@5 732
Chris@5 733 // Expected Page Cache header value: X-Drupal-Cache header.
Chris@5 734 if ($expected_page_cache_header_value !== FALSE) {
Chris@5 735 $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
Chris@5 736 $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
Chris@5 737 }
Chris@5 738 else {
Chris@5 739 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
Chris@5 740 }
Chris@5 741
Chris@5 742 // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
Chris@5 743 if ($expected_dynamic_page_cache_header_value !== FALSE) {
Chris@5 744 $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
Chris@5 745 $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
Chris@5 746 }
Chris@5 747 else {
Chris@5 748 $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
Chris@5 749 }
Chris@5 750 }
Chris@5 751
Chris@5 752 /**
Chris@5 753 * Asserts that an expected document matches the response body.
Chris@5 754 *
Chris@5 755 * @param array $expected_document
Chris@5 756 * The expected JSON:API document.
Chris@5 757 * @param array $actual_document
Chris@5 758 * The actual response document to assert.
Chris@5 759 */
Chris@5 760 protected function assertSameDocument(array $expected_document, array $actual_document) {
Chris@5 761 static::recursiveKsort($expected_document);
Chris@5 762 static::recursiveKsort($actual_document);
Chris@5 763
Chris@5 764 if (!empty($expected_document['included'])) {
Chris@5 765 static::sortResourceCollection($expected_document['included']);
Chris@5 766 static::sortResourceCollection($actual_document['included']);
Chris@5 767 }
Chris@5 768
Chris@5 769 if (isset($actual_document['meta']['omitted']) && isset($expected_document['meta']['omitted'])) {
Chris@5 770 $actual_omitted =& $actual_document['meta']['omitted'];
Chris@5 771 $expected_omitted =& $expected_document['meta']['omitted'];
Chris@5 772 static::sortOmittedLinks($actual_omitted);
Chris@5 773 static::sortOmittedLinks($expected_omitted);
Chris@5 774 static::resetOmittedLinkKeys($actual_omitted);
Chris@5 775 static::resetOmittedLinkKeys($expected_omitted);
Chris@5 776 }
Chris@5 777
Chris@5 778 $expected_keys = array_keys($expected_document);
Chris@5 779 $actual_keys = array_keys($actual_document);
Chris@5 780 $missing_member_names = array_diff($expected_keys, $actual_keys);
Chris@5 781 $extra_member_names = array_diff($actual_keys, $expected_keys);
Chris@5 782 if (!empty($missing_member_names) || !empty($extra_member_names)) {
Chris@5 783 $message_format = "The document members did not match the expected values. Missing: [ %s ]. Unexpected: [ %s ]";
Chris@5 784 $message = sprintf($message_format, implode(', ', $missing_member_names), implode(', ', $extra_member_names));
Chris@5 785 $this->assertSame($expected_document, $actual_document, $message);
Chris@5 786 }
Chris@5 787 foreach ($expected_document as $member_name => $expected_member) {
Chris@5 788 $actual_member = $actual_document[$member_name];
Chris@5 789 $this->assertSame($expected_member, $actual_member, "The '$member_name' member was not as expected.");
Chris@5 790 }
Chris@5 791 }
Chris@5 792
Chris@5 793 /**
Chris@5 794 * Asserts that a resource error response has the given message.
Chris@5 795 *
Chris@5 796 * @param int $expected_status_code
Chris@5 797 * The expected response status.
Chris@5 798 * @param string $expected_message
Chris@5 799 * The expected error message.
Chris@5 800 * @param \Drupal\Core\Url|null $via_link
Chris@5 801 * The source URL for the errors of the response. NULL if the error occurs
Chris@5 802 * for example during entity creation.
Chris@5 803 * @param \Psr\Http\Message\ResponseInterface $response
Chris@5 804 * The error response to assert.
Chris@5 805 * @param string|false $pointer
Chris@5 806 * The expected JSON Pointer to the associated entity in the request
Chris@5 807 * document. See http://jsonapi.org/format/#error-objects.
Chris@5 808 * @param string[]|false $expected_cache_tags
Chris@5 809 * (optional) The expected cache tags in the X-Drupal-Cache-Tags response
Chris@5 810 * header, or FALSE if that header should be absent. Defaults to FALSE.
Chris@5 811 * @param string[]|false $expected_cache_contexts
Chris@5 812 * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
Chris@5 813 * response header, or FALSE if that header should be absent. Defaults to
Chris@5 814 * FALSE.
Chris@5 815 * @param string|false $expected_page_cache_header_value
Chris@5 816 * (optional) The expected X-Drupal-Cache response header value, or FALSE if
Chris@5 817 * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
Chris@5 818 * to FALSE.
Chris@5 819 * @param string|false $expected_dynamic_page_cache_header_value
Chris@5 820 * (optional) The expected X-Drupal-Dynamic-Cache response header value, or
Chris@5 821 * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
Chris@5 822 * Defaults to FALSE.
Chris@5 823 */
Chris@5 824 protected function assertResourceErrorResponse($expected_status_code, $expected_message, $via_link, ResponseInterface $response, $pointer = FALSE, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
Chris@5 825 assert(is_null($via_link) || $via_link instanceof Url);
Chris@5 826 $expected_error = [];
Chris@5 827 if (!empty(Response::$statusTexts[$expected_status_code])) {
Chris@5 828 $expected_error['title'] = Response::$statusTexts[$expected_status_code];
Chris@5 829 }
Chris@5 830 $expected_error['status'] = (string) $expected_status_code;
Chris@5 831 $expected_error['detail'] = $expected_message;
Chris@5 832 if ($via_link) {
Chris@5 833 $expected_error['links']['via']['href'] = $via_link->setAbsolute()->toString();
Chris@5 834 }
Chris@5 835 if ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) {
Chris@5 836 $expected_error['links']['info']['href'] = $info_url;
Chris@5 837 }
Chris@5 838 if ($pointer !== FALSE) {
Chris@5 839 $expected_error['source']['pointer'] = $pointer;
Chris@5 840 }
Chris@5 841
Chris@5 842 $expected_document = [
Chris@5 843 'jsonapi' => static::$jsonApiMember,
Chris@5 844 'errors' => [
Chris@5 845 0 => $expected_error,
Chris@5 846 ],
Chris@5 847 ];
Chris@5 848 $this->assertResourceResponse($expected_status_code, $expected_document, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
Chris@5 849 }
Chris@5 850
Chris@5 851 /**
Chris@5 852 * Makes the JSON:API document violate the spec by omitting the resource type.
Chris@5 853 *
Chris@5 854 * @param array $document
Chris@5 855 * A JSON:API document.
Chris@5 856 *
Chris@5 857 * @return array
Chris@5 858 * The same JSON:API document, without its resource type.
Chris@5 859 */
Chris@5 860 protected function removeResourceTypeFromDocument(array $document) {
Chris@5 861 unset($document['data']['type']);
Chris@5 862 return $document;
Chris@5 863 }
Chris@5 864
Chris@5 865 /**
Chris@5 866 * Makes the given JSON:API document invalid.
Chris@5 867 *
Chris@5 868 * @param array $document
Chris@5 869 * A JSON:API document.
Chris@5 870 * @param string $entity_key
Chris@5 871 * The entity key whose normalization to make invalid.
Chris@5 872 *
Chris@5 873 * @return array
Chris@5 874 * The updated JSON:API document, now invalid.
Chris@5 875 */
Chris@5 876 protected function makeNormalizationInvalid(array $document, $entity_key) {
Chris@5 877 $entity_type = $this->entity->getEntityType();
Chris@5 878 switch ($entity_key) {
Chris@5 879 case 'label':
Chris@5 880 // Add a second label to this entity to make it invalid.
Chris@5 881 $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
Chris@5 882 $document['data']['attributes'][$label_field] = [
Chris@5 883 0 => $document['data']['attributes'][$label_field],
Chris@5 884 1 => 'Second Title',
Chris@5 885 ];
Chris@5 886 break;
Chris@5 887
Chris@5 888 case 'id':
Chris@5 889 $document['data']['attributes'][$entity_type->getKey('id')] = $this->anotherEntity->id();
Chris@5 890 break;
Chris@5 891
Chris@5 892 case 'uuid':
Chris@5 893 $document['data']['id'] = $this->anotherEntity->uuid();
Chris@5 894 break;
Chris@5 895 }
Chris@5 896
Chris@5 897 return $document;
Chris@5 898 }
Chris@5 899
Chris@5 900 /**
Chris@5 901 * Tests GETting an individual resource, plus edge cases to ensure good DX.
Chris@5 902 */
Chris@5 903 public function testGetIndividual() {
Chris@5 904 // The URL and Guzzle request options that will be used in this test. The
Chris@5 905 // request options will be modified/expanded throughout this test:
Chris@5 906 // - to first test all mistakes a developer might make, and assert that the
Chris@5 907 // error responses provide a good DX
Chris@5 908 // - to eventually result in a well-formed request that succeeds.
Chris@5 909 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 910 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
Chris@5 911 /* $url = $this->entity->toUrl('jsonapi'); */
Chris@5 912 $request_options = [];
Chris@5 913 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 914 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 915
Chris@5 916 // DX: 403 when unauthorized, or 200 if the 'view label' operation is
Chris@5 917 // supported by the entity type.
Chris@5 918 $response = $this->request('GET', $url, $request_options);
Chris@5 919 if (!static::$anonymousUsersCanViewLabels) {
Chris@5 920 $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
Chris@5 921 $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
Chris@5 922 $message = trim("The current user is not allowed to GET the selected resource. $reason");
Chris@5 923 $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 924 $this->assertArrayNotHasKey('Link', $response->getHeaders());
Chris@5 925 }
Chris@5 926 else {
Chris@5 927 $expected_document = $this->getExpectedDocument();
Chris@5 928 $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
Chris@5 929 $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]);
Chris@5 930 unset($expected_document['data']['relationships']);
Chris@5 931 // MISS or UNCACHEABLE depends on data. It must not be HIT.
Chris@5 932 $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE' : 'MISS';
Chris@5 933 $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), FALSE, $dynamic_cache_label_only);
Chris@5 934 }
Chris@5 935
Chris@5 936 $this->setUpAuthorization('GET');
Chris@5 937
Chris@5 938 // Set body despite that being nonsensical: should be ignored.
Chris@5 939 $request_options[RequestOptions::BODY] = Json::encode($this->getExpectedDocument());
Chris@5 940
Chris@5 941 // 400 for GET request with reserved custom query parameter.
Chris@5 942 $url_reserved_custom_query_parameter = clone $url;
Chris@5 943 $url_reserved_custom_query_parameter = $url_reserved_custom_query_parameter->setOption('query', ['foo' => 'bar']);
Chris@5 944 $response = $this->request('GET', $url_reserved_custom_query_parameter, $request_options);
Chris@5 945 $expected_document = [
Chris@5 946 'jsonapi' => static::$jsonApiMember,
Chris@5 947 'errors' => [
Chris@5 948 [
Chris@5 949 'title' => 'Bad Request',
Chris@5 950 'status' => '400',
Chris@5 951 'detail' => "The following query parameters violate the JSON:API spec: 'foo'.",
Chris@5 952 'links' => [
Chris@5 953 'info' => ['href' => 'http://jsonapi.org/format/#query-parameters'],
Chris@5 954 'via' => ['href' => $url_reserved_custom_query_parameter->toString()],
Chris@5 955 ],
Chris@5 956 ],
Chris@5 957 ],
Chris@5 958 ];
Chris@5 959 $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'MISS');
Chris@5 960
Chris@5 961 // 200 for well-formed HEAD request.
Chris@5 962 $response = $this->request('HEAD', $url, $request_options);
Chris@5 963 // MISS or UNCACHEABLE depends on data. It must not be HIT.
Chris@5 964 $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
Chris@5 965 $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache);
Chris@5 966 $head_headers = $response->getHeaders();
Chris@5 967
Chris@5 968 // 200 for well-formed GET request. Page Cache hit because of HEAD request.
Chris@5 969 // Same for Dynamic Page Cache hit.
Chris@5 970 $response = $this->request('GET', $url, $request_options);
Chris@5 971
Chris@5 972 $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
Chris@5 973 // Assert that Dynamic Page Cache did not store a ResourceResponse object,
Chris@5 974 // which needs serialization after every cache hit. Instead, it should
Chris@5 975 // contain a flattened response. Otherwise performance suffers.
Chris@5 976 // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
Chris@5 977 $cache_items = $this->container->get('database')
Chris@5 978 ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
Chris@5 979 ':pattern' => '%[route]=jsonapi.%',
Chris@5 980 ])
Chris@5 981 ->fetchAllAssoc('cid');
Chris@5 982 $this->assertTrue(count($cache_items) >= 2);
Chris@5 983 $found_cache_redirect = FALSE;
Chris@5 984 $found_cached_200_response = FALSE;
Chris@5 985 $other_cached_responses_are_4xx = TRUE;
Chris@5 986 foreach ($cache_items as $cid => $cache_item) {
Chris@5 987 $cached_data = unserialize($cache_item->data);
Chris@5 988 if (!isset($cached_data['#cache_redirect'])) {
Chris@5 989 $cached_response = $cached_data['#response'];
Chris@5 990 if ($cached_response->getStatusCode() === 200) {
Chris@5 991 $found_cached_200_response = TRUE;
Chris@5 992 }
Chris@5 993 elseif (!$cached_response->isClientError()) {
Chris@5 994 $other_cached_responses_are_4xx = FALSE;
Chris@5 995 }
Chris@5 996 $this->assertNotInstanceOf(ResourceResponse::class, $cached_response);
Chris@5 997 $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
Chris@5 998 }
Chris@5 999 else {
Chris@5 1000 $found_cache_redirect = TRUE;
Chris@5 1001 }
Chris@5 1002 }
Chris@5 1003 $this->assertTrue($found_cache_redirect);
Chris@5 1004 $this->assertSame($dynamic_cache !== 'UNCACHEABLE' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE', $found_cached_200_response);
Chris@5 1005 $this->assertTrue($other_cached_responses_are_4xx);
Chris@5 1006
Chris@5 1007 // Not only assert the normalization, also assert deserialization of the
Chris@5 1008 // response results in the expected object.
Chris@5 1009 $unserialized = $this->serializer->deserialize((string) $response->getBody(), JsonApiDocumentTopLevel::class, 'api_json', [
Chris@5 1010 'target_entity' => static::$entityTypeId,
Chris@5 1011 'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
Chris@5 1012 ]);
Chris@5 1013 $this->assertSame($unserialized->uuid(), $this->entity->uuid());
Chris@5 1014 $get_headers = $response->getHeaders();
Chris@5 1015
Chris@5 1016 // Verify that the GET and HEAD responses are the same. The only difference
Chris@5 1017 // is that there's no body. For this reason the 'Transfer-Encoding' and
Chris@5 1018 // 'Vary' headers are also added to the list of headers to ignore, as they
Chris@5 1019 // may be added to GET requests, depending on web server configuration. They
Chris@5 1020 // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
Chris@5 1021 $ignored_headers = [
Chris@5 1022 'Date',
Chris@5 1023 'Content-Length',
Chris@5 1024 'X-Drupal-Cache',
Chris@5 1025 'X-Drupal-Dynamic-Cache',
Chris@5 1026 'Transfer-Encoding',
Chris@5 1027 'Vary',
Chris@5 1028 ];
Chris@5 1029 $header_cleaner = function ($headers) use ($ignored_headers) {
Chris@5 1030 foreach ($headers as $header => $value) {
Chris@5 1031 if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
Chris@5 1032 unset($headers[$header]);
Chris@5 1033 }
Chris@5 1034 }
Chris@5 1035 return $headers;
Chris@5 1036 };
Chris@5 1037 $get_headers = $header_cleaner($get_headers);
Chris@5 1038 $head_headers = $header_cleaner($head_headers);
Chris@5 1039 $this->assertSame($get_headers, $head_headers);
Chris@5 1040
Chris@5 1041 // Feature: Sparse fieldsets.
Chris@5 1042 $this->doTestSparseFieldSets($url, $request_options);
Chris@5 1043 // Feature: Included.
Chris@5 1044 $this->doTestIncluded($url, $request_options);
Chris@5 1045
Chris@5 1046 // DX: 404 when GETting non-existing entity.
Chris@5 1047 $random_uuid = \Drupal::service('uuid')->generate();
Chris@5 1048 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $random_uuid]);
Chris@5 1049 $response = $this->request('GET', $url, $request_options);
Chris@5 1050 $message_url = clone $url;
Chris@5 1051 $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
Chris@5 1052 $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
Chris@5 1053 $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
Chris@5 1054
Chris@5 1055 // DX: when Accept request header is missing, still 404, same response.
Chris@5 1056 unset($request_options[RequestOptions::HEADERS]['Accept']);
Chris@5 1057 $response = $this->request('GET', $url, $request_options);
Chris@5 1058 $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
Chris@5 1059 }
Chris@5 1060
Chris@5 1061 /**
Chris@5 1062 * Tests GETting a collection of resources.
Chris@5 1063 */
Chris@5 1064 public function testCollection() {
Chris@5 1065 $entity_collection = $this->getData();
Chris@5 1066 assert(count($entity_collection) > 1, 'A collection must have more that one entity in it.');
Chris@5 1067
Chris@5 1068 $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE);
Chris@5 1069 $request_options = [];
Chris@5 1070 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 1071 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 1072
Chris@5 1073 // This asserts that collections will work without a sort, added by default
Chris@5 1074 // below, without actually asserting the content of the response.
Chris@5 1075 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
Chris@5 1076 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1077 $response = $this->request('HEAD', $collection_url, $request_options);
Chris@5 1078 // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
Chris@5 1079 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS';
Chris@5 1080 $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1081
Chris@5 1082 // Different databases have different sort orders, so a sort is required so
Chris@5 1083 // test expectations do not need to vary per database.
Chris@5 1084 $default_sort = ['sort' => 'drupal_internal__' . $this->entity->getEntityType()->getKey('id')];
Chris@5 1085 $collection_url->setOption('query', $default_sort);
Chris@5 1086
Chris@5 1087 // 200 for collections, even when all entities are inaccessible. Access is
Chris@5 1088 // on a per-entity basis, which is handled by
Chris@5 1089 // self::getExpectedCollectionResponse().
Chris@5 1090 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
Chris@5 1091 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1092 $expected_document = $expected_response->getResponseData();
Chris@5 1093 $response = $this->request('GET', $collection_url, $request_options);
Chris@5 1094 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1095
Chris@5 1096 $this->setUpAuthorization('GET');
Chris@5 1097
Chris@5 1098 // 200 for well-formed HEAD request.
Chris@5 1099 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
Chris@5 1100 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1101 $response = $this->request('HEAD', $collection_url, $request_options);
Chris@5 1102 $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1103
Chris@5 1104 // 200 for well-formed GET request.
Chris@5 1105 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
Chris@5 1106 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1107 $expected_document = $expected_response->getResponseData();
Chris@5 1108 $response = $this->request('GET', $collection_url, $request_options);
Chris@5 1109 // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE.
Chris@5 1110 $dynamic_cache = $dynamic_cache === 'UNCACHEABLE' ? 'UNCACHEABLE' : 'HIT';
Chris@5 1111 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1112
Chris@5 1113 if ($this->entity instanceof FieldableEntityInterface) {
Chris@5 1114 // 403 for filtering on an unauthorized field on the base resource type.
Chris@5 1115 $unauthorized_filter_url = clone $collection_url;
Chris@5 1116 $unauthorized_filter_url->setOption('query', [
Chris@5 1117 'filter' => [
Chris@5 1118 'related_author_id' => [
Chris@5 1119 'operator' => '<>',
Chris@5 1120 'path' => 'field_jsonapi_test_entity_ref.status',
Chris@5 1121 'value' => 'doesnt@matter.com',
Chris@5 1122 ],
Chris@5 1123 ],
Chris@5 1124 ]);
Chris@5 1125 $response = $this->request('GET', $unauthorized_filter_url, $request_options);
Chris@5 1126 $expected_error_message = "The current user is not authorized to filter by the `field_jsonapi_test_entity_ref` field, given in the path `field_jsonapi_test_entity_ref`. The 'field_jsonapi_test_entity_ref view access' permission is required.";
Chris@5 1127 $expected_cache_tags = ['4xx-response', 'http_response'];
Chris@5 1128 $expected_cache_contexts = [
Chris@5 1129 'url.query_args:filter',
Chris@5 1130 'url.query_args:sort',
Chris@5 1131 'url.site',
Chris@5 1132 'user.permissions',
Chris@5 1133 ];
Chris@5 1134 $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 1135
Chris@5 1136 $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']);
Chris@5 1137
Chris@5 1138 // 403 for filtering on an unauthorized field on a related resource type.
Chris@5 1139 $response = $this->request('GET', $unauthorized_filter_url, $request_options);
Chris@5 1140 $expected_error_message = "The current user is not authorized to filter by the `status` field, given in the path `field_jsonapi_test_entity_ref.entity:user.status`.";
Chris@5 1141 $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 1142 }
Chris@5 1143
Chris@5 1144 // Remove an entity from the collection, then filter it out.
Chris@5 1145 $filtered_entity_collection = $entity_collection;
Chris@5 1146 $removed = array_shift($filtered_entity_collection);
Chris@5 1147 $filtered_collection_url = clone $collection_url;
Chris@5 1148 $entity_collection_filter = [
Chris@5 1149 'filter' => [
Chris@5 1150 'ids' => [
Chris@5 1151 'condition' => [
Chris@5 1152 'operator' => '<>',
Chris@5 1153 'path' => 'id',
Chris@5 1154 'value' => $removed->uuid(),
Chris@5 1155 ],
Chris@5 1156 ],
Chris@5 1157 ],
Chris@5 1158 ];
Chris@5 1159 $filtered_collection_url->setOption('query', $entity_collection_filter + $default_sort);
Chris@5 1160 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_url->toString(), $request_options, NULL, TRUE);
Chris@5 1161 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1162 $expected_document = $expected_response->getResponseData();
Chris@5 1163 $response = $this->request('GET', $filtered_collection_url, $request_options);
Chris@5 1164 // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
Chris@5 1165 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
Chris@5 1166 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1167
Chris@5 1168 // Filtered collection with includes.
Chris@5 1169 $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) {
Chris@5 1170 return array_unique(array_merge($relationship_field_names, $this->getRelationshipFieldNames($entity)));
Chris@5 1171 }, []);
Chris@5 1172 $include = ['include' => implode(',', $relationship_field_names)];
Chris@5 1173 $filtered_collection_include_url = clone $collection_url;
Chris@5 1174 $filtered_collection_include_url->setOption('query', $entity_collection_filter + $include + $default_sort);
Chris@5 1175 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
Chris@5 1176 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1177 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
Chris@5 1178 $expected_document = $expected_response->getResponseData();
Chris@5 1179 $response = $this->request('GET', $filtered_collection_include_url, $request_options);
Chris@5 1180 // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
Chris@5 1181 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
Chris@5 1182 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1183
Chris@5 1184 // If the response should vary by a user's authorizations, grant permissions
Chris@5 1185 // for the included resources and execute another request.
Chris@5 1186 $permission_related_cache_contexts = [
Chris@5 1187 'user',
Chris@5 1188 'user.permissions',
Chris@5 1189 'user.roles',
Chris@5 1190 ];
Chris@5 1191 if (!empty($relationship_field_names) && !empty(array_intersect($expected_cacheability->getCacheContexts(), $permission_related_cache_contexts))) {
Chris@5 1192 $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($relationship_field_names));
Chris@5 1193 $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
Chris@5 1194 $this->grantPermissionsToTestedRole($flattened_permissions);
Chris@5 1195 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
Chris@5 1196 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1197 $expected_document = $expected_response->getResponseData();
Chris@5 1198 $response = $this->request('GET', $filtered_collection_include_url, $request_options);
Chris@5 1199 $requires_include_only_permissions = !empty($flattened_permissions);
Chris@5 1200 $uncacheable = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts()));
Chris@5 1201 $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE';
Chris@5 1202 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1203 }
Chris@5 1204
Chris@5 1205 // Sorted collection with includes.
Chris@5 1206 $sorted_entity_collection = $entity_collection;
Chris@5 1207 uasort($sorted_entity_collection, function (EntityInterface $a, EntityInterface $b) {
Chris@5 1208 // Sort by ID in reverse order.
Chris@5 1209 return strcmp($b->uuid(), $a->uuid());
Chris@5 1210 });
Chris@5 1211 $sorted_collection_include_url = clone $collection_url;
Chris@5 1212 $sorted_collection_include_url->setOption('query', $include + ['sort' => "-id"]);
Chris@5 1213 $expected_response = $this->getExpectedCollectionResponse($sorted_entity_collection, $sorted_collection_include_url->toString(), $request_options, $relationship_field_names);
Chris@5 1214 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 1215 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
Chris@5 1216 $expected_document = $expected_response->getResponseData();
Chris@5 1217 $response = $this->request('GET', $sorted_collection_include_url, $request_options);
Chris@5 1218 // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
Chris@5 1219 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS';
Chris@5 1220 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 1221 }
Chris@5 1222
Chris@5 1223 /**
Chris@5 1224 * Returns a JSON:API collection document for the expected entities.
Chris@5 1225 *
Chris@5 1226 * @param \Drupal\Core\Entity\EntityInterface[] $collection
Chris@5 1227 * The entities for the collection.
Chris@5 1228 * @param string $self_link
Chris@5 1229 * The self link for the collection response document.
Chris@5 1230 * @param array $request_options
Chris@5 1231 * Request options to apply.
Chris@5 1232 * @param array|null $included_paths
Chris@5 1233 * (optional) Any include paths that should be appended to the expected
Chris@5 1234 * response.
Chris@5 1235 * @param bool $filtered
Chris@5 1236 * Whether the collection is filtered or not.
Chris@5 1237 *
Chris@5 1238 * @return \Drupal\jsonapi\ResourceResponse
Chris@5 1239 * A ResourceResponse for the expected entity collection.
Chris@5 1240 *
Chris@5 1241 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1242 */
Chris@5 1243 protected function getExpectedCollectionResponse(array $collection, $self_link, array $request_options, array $included_paths = NULL, $filtered = FALSE) {
Chris@5 1244 $resource_identifiers = array_map([static::class, 'toResourceIdentifier'], $collection);
Chris@5 1245 $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
Chris@5 1246 $merged_response = static::toCollectionResourceResponse($individual_responses, $self_link, TRUE);
Chris@5 1247
Chris@5 1248 $merged_document = $merged_response->getResponseData();
Chris@5 1249 if (!isset($merged_document['data'])) {
Chris@5 1250 $merged_document['data'] = [];
Chris@5 1251 }
Chris@5 1252
Chris@5 1253 $cacheability = static::getExpectedCollectionCacheability($this->account, $collection, NULL, $filtered);
Chris@5 1254 $cacheability->setCacheMaxAge($merged_response->getCacheableMetadata()->getCacheMaxAge());
Chris@5 1255
Chris@5 1256 $collection_response = ResourceResponse::create($merged_document);
Chris@5 1257 $collection_response->addCacheableDependency($cacheability);
Chris@5 1258
Chris@5 1259 if (is_null($included_paths)) {
Chris@5 1260 return $collection_response;
Chris@5 1261 }
Chris@5 1262
Chris@5 1263 $related_responses = array_reduce($collection, function ($related_responses, EntityInterface $entity) use ($included_paths, $request_options, $self_link) {
Chris@5 1264 if (!$entity->access('view', $this->account) && !$entity->access('view label', $this->account)) {
Chris@5 1265 return $related_responses;
Chris@5 1266 }
Chris@5 1267 $expected_related_responses = $this->getExpectedRelatedResponses($included_paths, $request_options, $entity);
Chris@5 1268 if (empty($related_responses)) {
Chris@5 1269 return $expected_related_responses;
Chris@5 1270 }
Chris@5 1271 foreach ($included_paths as $included_path) {
Chris@5 1272 $both_responses = [$related_responses[$included_path], $expected_related_responses[$included_path]];
Chris@5 1273 $related_responses[$included_path] = static::toCollectionResourceResponse($both_responses, $self_link, TRUE);
Chris@5 1274 }
Chris@5 1275 return $related_responses;
Chris@5 1276 }, []);
Chris@5 1277
Chris@5 1278 return static::decorateExpectedResponseForIncludedFields($collection_response, $related_responses);
Chris@5 1279 }
Chris@5 1280
Chris@5 1281 /**
Chris@5 1282 * Tests GETing related resource of an individual resource.
Chris@5 1283 *
Chris@5 1284 * Expected responses are built by making requests to 'relationship' routes.
Chris@5 1285 * Using the fetched resource identifiers, if any, all targeted resources are
Chris@5 1286 * fetched individually. These individual responses are then 'merged' into a
Chris@5 1287 * single expected ResourceResponse. This is repeated for every relationship
Chris@5 1288 * field of the resource type under test.
Chris@5 1289 */
Chris@5 1290 public function testRelated() {
Chris@5 1291 $request_options = [];
Chris@5 1292 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 1293 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 1294 $this->doTestRelated($request_options);
Chris@5 1295 $this->setUpAuthorization('GET');
Chris@5 1296 $this->doTestRelated($request_options);
Chris@5 1297 }
Chris@5 1298
Chris@5 1299 /**
Chris@5 1300 * Tests CRUD of individual resource relationship data.
Chris@5 1301 *
Chris@5 1302 * Unlike the "related" routes, relationship routes only return information
Chris@5 1303 * about the "relationship" itself, not the targeted resources. For JSON:API
Chris@5 1304 * with Drupal, relationship routes are like looking at an entity reference
Chris@5 1305 * field without loading the entities. It only reveals the type of the
Chris@5 1306 * targeted resource and the target resource IDs. These type+ID combos are
Chris@5 1307 * referred to as "resource identifiers."
Chris@5 1308 */
Chris@5 1309 public function testRelationships() {
Chris@5 1310 if ($this->entity instanceof ConfigEntityInterface) {
Chris@5 1311 $this->markTestSkipped('Configuration entities cannot have relationships.');
Chris@5 1312 }
Chris@5 1313
Chris@5 1314 $request_options = [];
Chris@5 1315 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 1316 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 1317
Chris@5 1318 // Test GET.
Chris@5 1319 $this->doTestRelationshipGet($request_options);
Chris@5 1320 $this->setUpAuthorization('GET');
Chris@5 1321 $this->doTestRelationshipGet($request_options);
Chris@5 1322
Chris@5 1323 // Test POST.
Chris@5 1324 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
Chris@5 1325 $this->doTestRelationshipMutation($request_options);
Chris@5 1326 // Grant entity-level edit access.
Chris@5 1327 $this->setUpAuthorization('PATCH');
Chris@5 1328 $this->doTestRelationshipMutation($request_options);
Chris@5 1329 // Field edit access is still forbidden, grant it.
Chris@5 1330 $this->grantPermissionsToTestedRole([
Chris@5 1331 'field_jsonapi_test_entity_ref view access',
Chris@5 1332 'field_jsonapi_test_entity_ref edit access',
Chris@5 1333 'field_jsonapi_test_entity_ref update access',
Chris@5 1334 ]);
Chris@5 1335 $this->doTestRelationshipMutation($request_options);
Chris@5 1336 }
Chris@5 1337
Chris@5 1338 /**
Chris@5 1339 * Performs one round of related route testing.
Chris@5 1340 *
Chris@5 1341 * By putting this behavior in its own method, authorization and other
Chris@5 1342 * variations can be done in the calling method around assertions. For
Chris@5 1343 * example, it can be run once with an authorized user and again without one.
Chris@5 1344 *
Chris@5 1345 * @param array $request_options
Chris@5 1346 * Request options to apply.
Chris@5 1347 *
Chris@5 1348 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1349 */
Chris@5 1350 protected function doTestRelated(array $request_options) {
Chris@5 1351 $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
Chris@5 1352 // If there are no relationship fields, we can't test related routes.
Chris@5 1353 if (empty($relationship_field_names)) {
Chris@5 1354 return;
Chris@5 1355 }
Chris@5 1356 // Builds an array of expected responses, keyed by relationship field name.
Chris@5 1357 $expected_relationship_responses = $this->getExpectedRelatedResponses($relationship_field_names, $request_options);
Chris@5 1358 // Fetches actual responses as an array keyed by relationship field name.
Chris@5 1359 $related_responses = $this->getRelatedResponses($relationship_field_names, $request_options);
Chris@5 1360 foreach ($relationship_field_names as $relationship_field_name) {
Chris@5 1361 /* @var \Drupal\jsonapi\ResourceResponse $expected_resource_response */
Chris@5 1362 $expected_resource_response = $expected_relationship_responses[$relationship_field_name];
Chris@5 1363 /* @var \Psr\Http\Message\ResponseInterface $actual_response */
Chris@5 1364 $actual_response = $related_responses[$relationship_field_name];
Chris@5 1365 // Dynamic Page Cache miss because cache should vary based on the
Chris@5 1366 // 'include' query param.
Chris@5 1367 $expected_cacheability = $expected_resource_response->getCacheableMetadata();
Chris@5 1368 $this->assertResourceResponse(
Chris@5 1369 $expected_resource_response->getStatusCode(),
Chris@5 1370 $expected_resource_response->getResponseData(),
Chris@5 1371 $actual_response,
Chris@5 1372 $expected_cacheability->getCacheTags(),
Chris@5 1373 $expected_cacheability->getCacheContexts(),
Chris@5 1374 FALSE,
Chris@5 1375 $actual_response->getStatusCode() === 200
Chris@5 1376 ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS')
Chris@5 1377 : FALSE
Chris@5 1378 );
Chris@5 1379 }
Chris@5 1380 }
Chris@5 1381
Chris@5 1382 /**
Chris@5 1383 * Performs one round of relationship route testing.
Chris@5 1384 *
Chris@5 1385 * @param array $request_options
Chris@5 1386 * Request options to apply.
Chris@5 1387 *
Chris@5 1388 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1389 * @see ::testRelationships
Chris@5 1390 */
Chris@5 1391 protected function doTestRelationshipGet(array $request_options) {
Chris@5 1392 $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
Chris@5 1393 // If there are no relationship fields, we can't test relationship routes.
Chris@5 1394 if (empty($relationship_field_names)) {
Chris@5 1395 return;
Chris@5 1396 }
Chris@5 1397
Chris@5 1398 // Test GET.
Chris@5 1399 $related_responses = $this->getRelationshipResponses($relationship_field_names, $request_options);
Chris@5 1400 foreach ($relationship_field_names as $relationship_field_name) {
Chris@5 1401 $expected_resource_response = $this->getExpectedGetRelationshipResponse($relationship_field_name);
Chris@5 1402 $expected_document = $expected_resource_response->getResponseData();
Chris@5 1403 $expected_cacheability = $expected_resource_response->getCacheableMetadata();
Chris@5 1404 $actual_response = $related_responses[$relationship_field_name];
Chris@5 1405 $this->assertResourceResponse(
Chris@5 1406 $expected_resource_response->getStatusCode(),
Chris@5 1407 $expected_document,
Chris@5 1408 $actual_response,
Chris@5 1409 $expected_cacheability->getCacheTags(),
Chris@5 1410 $expected_cacheability->getCacheContexts(),
Chris@5 1411 FALSE,
Chris@5 1412 $expected_resource_response->isSuccessful() ? 'MISS' : FALSE
Chris@5 1413 );
Chris@5 1414 }
Chris@5 1415 }
Chris@5 1416
Chris@5 1417 /**
Chris@5 1418 * Performs one round of relationship POST, PATCH and DELETE route testing.
Chris@5 1419 *
Chris@5 1420 * @param array $request_options
Chris@5 1421 * Request options to apply.
Chris@5 1422 *
Chris@5 1423 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1424 * @see ::testRelationships
Chris@5 1425 */
Chris@5 1426 protected function doTestRelationshipMutation(array $request_options) {
Chris@5 1427 /* @var \Drupal\Core\Entity\FieldableEntityInterface $resource */
Chris@5 1428 $resource = $this->createAnotherEntity('dupe');
Chris@5 1429 $resource->set('field_jsonapi_test_entity_ref', NULL);
Chris@5 1430 $violations = $resource->validate();
Chris@5 1431 assert($violations->count() === 0, (string) $violations);
Chris@5 1432 $resource->save();
Chris@5 1433 $target_resource = $this->createUser();
Chris@5 1434 $violations = $target_resource->validate();
Chris@5 1435 assert($violations->count() === 0, (string) $violations);
Chris@5 1436 $target_resource->save();
Chris@5 1437 $target_identifier = static::toResourceIdentifier($target_resource);
Chris@5 1438 $resource_identifier = static::toResourceIdentifier($resource);
Chris@5 1439 $relationship_field_name = 'field_jsonapi_test_entity_ref';
Chris@5 1440 /* @var \Drupal\Core\Access\AccessResultReasonInterface $update_access */
Chris@5 1441 $update_access = static::entityAccess($resource, 'update', $this->account)
Chris@5 1442 ->andIf(static::entityFieldAccess($resource, $relationship_field_name, 'edit', $this->account));
Chris@5 1443 $url = Url::fromRoute(sprintf("jsonapi.{$resource_identifier['type']}.{$relationship_field_name}.relationship.patch"), [
Chris@5 1444 'entity' => $resource->uuid(),
Chris@5 1445 ]);
Chris@5 1446
Chris@5 1447 // Test POST: missing content-type.
Chris@5 1448 $response = $this->request('POST', $url, $request_options);
Chris@5 1449 $this->assertSame(415, $response->getStatusCode());
Chris@5 1450
Chris@5 1451 // Set the JSON:API media type header for all subsequent requests.
Chris@5 1452 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 1453
Chris@5 1454 if ($update_access->isAllowed()) {
Chris@5 1455 // Test POST: empty body.
Chris@5 1456 $response = $this->request('POST', $url, $request_options);
Chris@5 1457 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
Chris@5 1458 // Test PATCH: empty body.
Chris@5 1459 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1460 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
Chris@5 1461
Chris@5 1462 // Test POST: empty data.
Chris@5 1463 $request_options[RequestOptions::BODY] = Json::encode(['data' => []]);
Chris@5 1464 $response = $this->request('POST', $url, $request_options);
Chris@5 1465 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1466 // Test PATCH: empty data.
Chris@5 1467 $request_options[RequestOptions::BODY] = Json::encode(['data' => []]);
Chris@5 1468 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1469 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1470
Chris@5 1471 // Test POST: data as resource identifier, not array of identifiers.
Chris@5 1472 $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]);
Chris@5 1473 $response = $this->request('POST', $url, $request_options);
Chris@5 1474 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
Chris@5 1475 // Test PATCH: data as resource identifier, not array of identifiers.
Chris@5 1476 $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]);
Chris@5 1477 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1478 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
Chris@5 1479
Chris@5 1480 // Test POST: missing the 'type' field.
Chris@5 1481 $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]);
Chris@5 1482 $response = $this->request('POST', $url, $request_options);
Chris@5 1483 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
Chris@5 1484 // Test PATCH: missing the 'type' field.
Chris@5 1485 $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]);
Chris@5 1486 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1487 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
Chris@5 1488
Chris@5 1489 // If the base resource type is the same as that of the target's (as it
Chris@5 1490 // will be for `user--user`), then the validity error will not be
Chris@5 1491 // triggered, needlessly failing this assertion.
Chris@5 1492 if (static::$resourceTypeName !== $target_identifier['type']) {
Chris@5 1493 // Test POST: invalid target.
Chris@5 1494 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]);
Chris@5 1495 $response = $this->request('POST', $url, $request_options);
Chris@5 1496 $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE);
Chris@5 1497 // Test PATCH: invalid target.
Chris@5 1498 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]);
Chris@5 1499 $response = $this->request('POST', $url, $request_options);
Chris@5 1500 $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE);
Chris@5 1501 }
Chris@5 1502
Chris@5 1503 // Test POST: duplicate targets, no arity.
Chris@5 1504 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]);
Chris@5 1505 $response = $this->request('POST', $url, $request_options);
Chris@5 1506 $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
Chris@5 1507
Chris@5 1508 // Test PATCH: duplicate targets, no arity.
Chris@5 1509 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]);
Chris@5 1510 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1511 $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
Chris@5 1512
Chris@5 1513 // Test POST: success.
Chris@5 1514 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
Chris@5 1515 $response = $this->request('POST', $url, $request_options);
Chris@5 1516 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1517
Chris@5 1518 // Test POST: success, relationship already exists, no arity.
Chris@5 1519 $response = $this->request('POST', $url, $request_options);
Chris@5 1520 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1521
Chris@5 1522 // Test POST: success, relationship already exists, new arity.
Chris@5 1523 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier + ['meta' => ['arity' => 1]]]]);
Chris@5 1524 $response = $this->request('POST', $url, $request_options);
Chris@5 1525 $resource->set($relationship_field_name, [$target_resource, $target_resource]);
Chris@5 1526 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1527 $expected_document['data'][0] += ['meta' => ['arity' => 0]];
Chris@5 1528 $expected_document['data'][1] += ['meta' => ['arity' => 1]];
Chris@5 1529 $this->assertResourceResponse(200, $expected_document, $response);
Chris@5 1530
Chris@5 1531 // Test PATCH: success, new value is the same as given value.
Chris@5 1532 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1533 'data' => [
Chris@5 1534 $target_identifier + ['meta' => ['arity' => 0]],
Chris@5 1535 $target_identifier + ['meta' => ['arity' => 1]],
Chris@5 1536 ],
Chris@5 1537 ]);
Chris@5 1538 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1539 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1540
Chris@5 1541 // Test POST: success, relationship already exists, new arity.
Chris@5 1542 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1543 'data' => [
Chris@5 1544 $target_identifier + ['meta' => ['arity' => 2]],
Chris@5 1545 ],
Chris@5 1546 ]);
Chris@5 1547 $response = $this->request('POST', $url, $request_options);
Chris@5 1548 $resource->set($relationship_field_name, [
Chris@5 1549 $target_resource,
Chris@5 1550 $target_resource,
Chris@5 1551 $target_resource,
Chris@5 1552 ]);
Chris@5 1553 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1554 $expected_document['data'][0] += ['meta' => ['arity' => 0]];
Chris@5 1555 $expected_document['data'][1] += ['meta' => ['arity' => 1]];
Chris@5 1556 $expected_document['data'][2] += ['meta' => ['arity' => 2]];
Chris@5 1557 // 200 with response body because the request did not include the
Chris@5 1558 // existing relationship resource identifier object.
Chris@5 1559 $this->assertResourceResponse(200, $expected_document, $response);
Chris@5 1560
Chris@5 1561 // Test POST: success.
Chris@5 1562 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1563 'data' => [
Chris@5 1564 $target_identifier + ['meta' => ['arity' => 0]],
Chris@5 1565 $target_identifier + ['meta' => ['arity' => 1]],
Chris@5 1566 ],
Chris@5 1567 ]);
Chris@5 1568 $response = $this->request('POST', $url, $request_options);
Chris@5 1569 // 200 with response body because the request did not include the
Chris@5 1570 // resource identifier with arity 2.
Chris@5 1571 $this->assertResourceResponse(200, $expected_document, $response);
Chris@5 1572
Chris@5 1573 // Test PATCH: success.
Chris@5 1574 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1575 'data' => [
Chris@5 1576 $target_identifier + ['meta' => ['arity' => 0]],
Chris@5 1577 $target_identifier + ['meta' => ['arity' => 1]],
Chris@5 1578 $target_identifier + ['meta' => ['arity' => 2]],
Chris@5 1579 ],
Chris@5 1580 ]);
Chris@5 1581 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1582 // 204 no content. PATCH data matches existing data.
Chris@5 1583 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1584
Chris@5 1585 // Test DELETE: three existing relationships, two removed.
Chris@5 1586 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1587 'data' => [
Chris@5 1588 $target_identifier + ['meta' => ['arity' => 0]],
Chris@5 1589 $target_identifier + ['meta' => ['arity' => 2]],
Chris@5 1590 ],
Chris@5 1591 ]);
Chris@5 1592 $response = $this->request('DELETE', $url, $request_options);
Chris@5 1593 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1594 // Subsequent GET should return only one resource identifier, with no
Chris@5 1595 // arity.
Chris@5 1596 $resource->set($relationship_field_name, [$target_resource]);
Chris@5 1597 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1598 $response = $this->request('GET', $url, $request_options);
Chris@5 1599 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
Chris@5 1600
Chris@5 1601 // Test DELETE: one existing relationship, removed.
Chris@5 1602 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
Chris@5 1603 $response = $this->request('DELETE', $url, $request_options);
Chris@5 1604 $resource->set($relationship_field_name, []);
Chris@5 1605 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1606 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1607 $response = $this->request('GET', $url, $request_options);
Chris@5 1608 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
Chris@5 1609
Chris@5 1610 // Test DELETE: no existing relationships, no op, success.
Chris@5 1611 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
Chris@5 1612 $response = $this->request('DELETE', $url, $request_options);
Chris@5 1613 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1614 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1615 $response = $this->request('GET', $url, $request_options);
Chris@5 1616 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
Chris@5 1617
Chris@5 1618 // Test PATCH: success, new value is different than existing value.
Chris@5 1619 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 1620 'data' => [
Chris@5 1621 $target_identifier + ['meta' => ['arity' => 2]],
Chris@5 1622 $target_identifier + ['meta' => ['arity' => 3]],
Chris@5 1623 ],
Chris@5 1624 ]);
Chris@5 1625 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1626 $resource->set($relationship_field_name, [$target_resource, $target_resource]);
Chris@5 1627 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1628 $expected_document['data'][0] += ['meta' => ['arity' => 0]];
Chris@5 1629 $expected_document['data'][1] += ['meta' => ['arity' => 1]];
Chris@5 1630 // 200 with response body because arity values are computed; that means
Chris@5 1631 // that the PATCH arity values 2 + 3 will become 0 + 1 if there are not
Chris@5 1632 // already resource identifiers with those arity values.
Chris@5 1633 $this->assertResourceResponse(200, $expected_document, $response);
Chris@5 1634
Chris@5 1635 // Test DELETE: two existing relationships, both removed because no arity
Chris@5 1636 // was specified.
Chris@5 1637 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
Chris@5 1638 $response = $this->request('DELETE', $url, $request_options);
Chris@5 1639 $resource->set($relationship_field_name, []);
Chris@5 1640 $this->assertResourceResponse(204, NULL, $response);
Chris@5 1641 $resource->set($relationship_field_name, []);
Chris@5 1642 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
Chris@5 1643 $response = $this->request('GET', $url, $request_options);
Chris@5 1644 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
Chris@5 1645 }
Chris@5 1646 else {
Chris@5 1647 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
Chris@5 1648 $response = $this->request('POST', $url, $request_options);
Chris@5 1649 $message = 'The current user is not allowed to edit this relationship.';
Chris@5 1650 $message .= ($reason = $update_access->getReason()) ? ' ' . $reason : '';
Chris@5 1651 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
Chris@5 1652 $response = $this->request('PATCH', $url, $request_options);
Chris@5 1653 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
Chris@5 1654 $response = $this->request('DELETE', $url, $request_options);
Chris@5 1655 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
Chris@5 1656 }
Chris@5 1657
Chris@5 1658 // Remove the test entities that were created.
Chris@5 1659 $resource->delete();
Chris@5 1660 $target_resource->delete();
Chris@5 1661 }
Chris@5 1662
Chris@5 1663 /**
Chris@5 1664 * Gets an expected ResourceResponse for the given relationship.
Chris@5 1665 *
Chris@5 1666 * @param string $relationship_field_name
Chris@5 1667 * The relationship for which to get an expected response.
Chris@5 1668 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@5 1669 * (optional) The entity for which to get expected relationship response.
Chris@5 1670 *
Chris@5 1671 * @return \Drupal\jsonapi\ResourceResponse
Chris@5 1672 * The expected ResourceResponse.
Chris@5 1673 */
Chris@5 1674 protected function getExpectedGetRelationshipResponse($relationship_field_name, EntityInterface $entity = NULL) {
Chris@5 1675 $entity = $entity ?: $this->entity;
Chris@5 1676 $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@5 1677 $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType->getInternalName($relationship_field_name), 'view', $this->account));
Chris@5 1678 if (!$access->isAllowed()) {
Chris@5 1679 $via_link = Url::fromRoute(
Chris@5 1680 sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, $relationship_field_name),
Chris@5 1681 ['entity' => $entity->uuid()]
Chris@5 1682 );
Chris@5 1683 return static::getAccessDeniedResponse($this->entity, $access, $via_link, $relationship_field_name, 'The current user is not allowed to view this relationship.', FALSE);
Chris@5 1684 }
Chris@5 1685 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $entity);
Chris@5 1686 $expected_cacheability = (new CacheableMetadata())
Chris@5 1687 ->addCacheTags(['http_response'])
Chris@5 1688 ->addCacheContexts([
Chris@5 1689 'url.site',
Chris@5 1690 'url.query_args:include',
Chris@5 1691 'url.query_args:fields',
Chris@5 1692 ])
Chris@5 1693 ->addCacheableDependency($entity)
Chris@5 1694 ->addCacheableDependency($access);
Chris@5 1695 $status_code = isset($expected_document['errors'][0]['status']) ? $expected_document['errors'][0]['status'] : 200;
Chris@5 1696 $resource_response = new ResourceResponse($expected_document, $status_code);
Chris@5 1697 $resource_response->addCacheableDependency($expected_cacheability);
Chris@5 1698 return $resource_response;
Chris@5 1699 }
Chris@5 1700
Chris@5 1701 /**
Chris@5 1702 * Gets an expected document for the given relationship.
Chris@5 1703 *
Chris@5 1704 * @param string $relationship_field_name
Chris@5 1705 * The relationship for which to get an expected response.
Chris@5 1706 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@5 1707 * (optional) The entity for which to get expected relationship document.
Chris@5 1708 *
Chris@5 1709 * @return array
Chris@5 1710 * The expected document array.
Chris@5 1711 */
Chris@5 1712 protected function getExpectedGetRelationshipDocument($relationship_field_name, EntityInterface $entity = NULL) {
Chris@5 1713 $entity = $entity ?: $this->entity;
Chris@5 1714 $entity_type_id = $entity->getEntityTypeId();
Chris@5 1715 $bundle = $entity->bundle();
Chris@5 1716 $id = $entity->uuid();
Chris@5 1717 $self_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/relationships/$relationship_field_name")->setAbsolute();
Chris@5 1718 $related_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/$relationship_field_name")->setAbsolute();
Chris@5 1719 if (static::$resourceTypeIsVersionable) {
Chris@5 1720 assert($entity instanceof RevisionableInterface);
Chris@5 1721 $version_query = ['resourceVersion' => 'id:' . $entity->getRevisionId()];
Chris@5 1722 $self_link->setOption('query', $version_query);
Chris@5 1723 $related_link->setOption('query', $version_query);
Chris@5 1724 }
Chris@5 1725 $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
Chris@5 1726 return [
Chris@5 1727 'data' => $data,
Chris@5 1728 'jsonapi' => static::$jsonApiMember,
Chris@5 1729 'links' => [
Chris@5 1730 'self' => ['href' => $self_link->toString(TRUE)->getGeneratedUrl()],
Chris@5 1731 'related' => ['href' => $related_link->toString(TRUE)->getGeneratedUrl()],
Chris@5 1732 ],
Chris@5 1733 ];
Chris@5 1734 }
Chris@5 1735
Chris@5 1736 /**
Chris@5 1737 * Gets the expected document data for the given relationship.
Chris@5 1738 *
Chris@5 1739 * @param string $relationship_field_name
Chris@5 1740 * The relationship for which to get an expected response.
Chris@5 1741 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@5 1742 * (optional) The entity for which to get expected relationship data.
Chris@5 1743 *
Chris@5 1744 * @return mixed
Chris@5 1745 * The expected document data.
Chris@5 1746 */
Chris@5 1747 protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
Chris@5 1748 $entity = $entity ?: $this->entity;
Chris@5 1749 $internal_field_name = $this->resourceType->getInternalName($relationship_field_name);
Chris@5 1750 /* @var \Drupal\Core\Field\FieldItemListInterface $field */
Chris@5 1751 $field = $entity->{$internal_field_name};
Chris@5 1752 $is_multiple = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() !== 1;
Chris@5 1753 if ($field->isEmpty()) {
Chris@5 1754 return $is_multiple ? [] : NULL;
Chris@5 1755 }
Chris@5 1756 if (!$is_multiple) {
Chris@5 1757 $target_entity = $field->entity;
Chris@5 1758 return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity);
Chris@5 1759 }
Chris@5 1760 else {
Chris@5 1761 return array_filter(array_map(function ($item) {
Chris@5 1762 $target_entity = $item->entity;
Chris@5 1763 return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity);
Chris@5 1764 }, iterator_to_array($field)));
Chris@5 1765 }
Chris@5 1766 }
Chris@5 1767
Chris@5 1768 /**
Chris@5 1769 * Builds an array of expected related ResourceResponses, keyed by field name.
Chris@5 1770 *
Chris@5 1771 * @param array $relationship_field_names
Chris@5 1772 * The relationship field names for which to build expected
Chris@5 1773 * ResourceResponses.
Chris@5 1774 * @param array $request_options
Chris@5 1775 * Request options to apply.
Chris@5 1776 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@5 1777 * (optional) The entity for which to get expected related resources.
Chris@5 1778 *
Chris@5 1779 * @return \Drupal\jsonapi\ResourceResponse[]
Chris@5 1780 * An array of expected ResourceResponses, keyed by their relationship field
Chris@5 1781 * name.
Chris@5 1782 *
Chris@5 1783 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1784 */
Chris@5 1785 protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
Chris@5 1786 $entity = $entity ?: $this->entity;
Chris@5 1787 return array_map(function ($relationship_field_name) use ($entity, $request_options) {
Chris@5 1788 return $this->getExpectedRelatedResponse($relationship_field_name, $request_options, $entity);
Chris@5 1789 }, array_combine($relationship_field_names, $relationship_field_names));
Chris@5 1790 }
Chris@5 1791
Chris@5 1792 /**
Chris@5 1793 * Builds an expected related ResourceResponse for the given field.
Chris@5 1794 *
Chris@5 1795 * @param string $relationship_field_name
Chris@5 1796 * The relationship field name for which to build an expected
Chris@5 1797 * ResourceResponse.
Chris@5 1798 * @param array $request_options
Chris@5 1799 * Request options to apply.
Chris@5 1800 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 1801 * The entity for which to get expected related resources.
Chris@5 1802 *
Chris@5 1803 * @return \Drupal\jsonapi\ResourceResponse
Chris@5 1804 * An expected ResourceResponse.
Chris@5 1805 *
Chris@5 1806 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 1807 */
Chris@5 1808 protected function getExpectedRelatedResponse($relationship_field_name, array $request_options, EntityInterface $entity) {
Chris@5 1809 // Get the relationships responses which contain resource identifiers for
Chris@5 1810 // every related resource.
Chris@5 1811 /* @var \Drupal\jsonapi\ResourceResponse[] $relationship_responses */
Chris@5 1812 $base_resource_identifier = static::toResourceIdentifier($entity);
Chris@5 1813 $internal_name = $this->resourceType->getInternalName($relationship_field_name);
Chris@5 1814 $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@5 1815 $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account));
Chris@5 1816 if (!$access->isAllowed()) {
Chris@5 1817 $detail = 'The current user is not allowed to view this relationship.';
Chris@5 1818 if (!$entity->access('view') && $entity->access('view label') && $access instanceof AccessResultReasonInterface && empty($access->getReason())) {
Chris@5 1819 $access->setReason("The user only has authorization for the 'view label' operation.");
Chris@5 1820 }
Chris@5 1821 $via_link = Url::fromRoute(
Chris@5 1822 sprintf('jsonapi.%s.%s.related', $base_resource_identifier['type'], $relationship_field_name),
Chris@5 1823 ['entity' => $base_resource_identifier['id']]
Chris@5 1824 );
Chris@5 1825 $related_response = static::getAccessDeniedResponse($entity, $access, $via_link, $relationship_field_name, $detail, FALSE);
Chris@5 1826 }
Chris@5 1827 else {
Chris@5 1828 $self_link = static::getRelatedLink($base_resource_identifier, $relationship_field_name);
Chris@5 1829 $relationship_response = $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity);
Chris@5 1830 $relationship_document = $relationship_response->getResponseData();
Chris@5 1831 // The relationships may be empty, in which case we shouldn't attempt to
Chris@5 1832 // fetch the individual identified resources.
Chris@5 1833 if (empty($relationship_document['data'])) {
Chris@5 1834 $cache_contexts = Cache::mergeContexts([
Chris@5 1835 // Cache contexts for JSON:API URL query parameters.
Chris@5 1836 'url.query_args:fields',
Chris@5 1837 'url.query_args:include',
Chris@5 1838 // Drupal defaults.
Chris@5 1839 'url.site',
Chris@5 1840 ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
Chris@5 1841 $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']);
Chris@5 1842 $related_response = isset($relationship_document['errors'])
Chris@5 1843 ? $relationship_response
Chris@5 1844 : (new ResourceResponse(static::getEmptyCollectionResponse(!is_null($relationship_document['data']), $self_link)->getResponseData()))->addCacheableDependency($cacheability);
Chris@5 1845 }
Chris@5 1846 else {
Chris@5 1847 $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']);
Chris@5 1848 $resource_identifiers = $is_to_one_relationship
Chris@5 1849 ? [$relationship_document['data']]
Chris@5 1850 : $relationship_document['data'];
Chris@5 1851 // Remove any relationships to 'virtual' resources.
Chris@5 1852 $resource_identifiers = array_filter($resource_identifiers, function ($resource_identifier) {
Chris@5 1853 return $resource_identifier['id'] !== 'virtual';
Chris@5 1854 });
Chris@5 1855 if (!empty($resource_identifiers)) {
Chris@5 1856 $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
Chris@5 1857 $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship);
Chris@5 1858 }
Chris@5 1859 else {
Chris@5 1860 $related_response = static::getEmptyCollectionResponse(!$is_to_one_relationship, $self_link);
Chris@5 1861 }
Chris@5 1862 }
Chris@5 1863 $related_response->addCacheableDependency($relationship_response->getCacheableMetadata());
Chris@5 1864 }
Chris@5 1865 return $related_response;
Chris@5 1866 }
Chris@5 1867
Chris@5 1868 /**
Chris@5 1869 * Tests POSTing an individual resource, plus edge cases to ensure good DX.
Chris@5 1870 */
Chris@5 1871 public function testPostIndividual() {
Chris@5 1872 // @todo Remove this in https://www.drupal.org/node/2300677.
Chris@5 1873 if ($this->entity instanceof ConfigEntityInterface) {
Chris@5 1874 $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
Chris@5 1875 return;
Chris@5 1876 }
Chris@5 1877
Chris@5 1878 // Try with all of the following request bodies.
Chris@5 1879 $unparseable_request_body = '!{>}<';
Chris@5 1880 $parseable_valid_request_body = Json::encode($this->getPostDocument());
Chris@5 1881 /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */
Chris@5 1882 $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type'));
Chris@5 1883 $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label'));
Chris@5 1884 $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument()));
Chris@5 1885 $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument()));
Chris@5 1886 $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument()));
Chris@5 1887
Chris@5 1888 // The URL and Guzzle request options that will be used in this test. The
Chris@5 1889 // request options will be modified/expanded throughout this test:
Chris@5 1890 // - to first test all mistakes a developer might make, and assert that the
Chris@5 1891 // error responses provide a good DX
Chris@5 1892 // - to eventually result in a well-formed request that succeeds.
Chris@5 1893 $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
Chris@5 1894 $request_options = [];
Chris@5 1895 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 1896 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 1897
Chris@5 1898 // DX: 405 when read-only mode is enabled.
Chris@5 1899 $response = $this->request('POST', $url, $request_options);
Chris@5 1900 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response);
Chris@5 1901 if ($this->resourceType->isLocatable()) {
Chris@5 1902 $this->assertSame(['GET'], $response->getHeader('Allow'));
Chris@5 1903 }
Chris@5 1904 else {
Chris@5 1905 $this->assertSame([''], $response->getHeader('Allow'));
Chris@5 1906 }
Chris@5 1907
Chris@5 1908 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
Chris@5 1909
Chris@5 1910 // DX: 415 when no Content-Type request header.
Chris@5 1911 $response = $this->request('POST', $url, $request_options);
Chris@5 1912 $this->assertSame(415, $response->getStatusCode());
Chris@5 1913
Chris@5 1914 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 1915
Chris@5 1916 // DX: 403 when unauthorized.
Chris@5 1917 $response = $this->request('POST', $url, $request_options);
Chris@5 1918 $reason = $this->getExpectedUnauthorizedAccessMessage('POST');
Chris@5 1919 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
Chris@5 1920
Chris@5 1921 $this->setUpAuthorization('POST');
Chris@5 1922
Chris@5 1923 // DX: 400 when no request body.
Chris@5 1924 $response = $this->request('POST', $url, $request_options);
Chris@5 1925 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
Chris@5 1926
Chris@5 1927 $request_options[RequestOptions::BODY] = $unparseable_request_body;
Chris@5 1928
Chris@5 1929 // DX: 400 when unparseable request body.
Chris@5 1930 $response = $this->request('POST', $url, $request_options);
Chris@5 1931 $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE);
Chris@5 1932
Chris@5 1933 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type;
Chris@5 1934
Chris@5 1935 // DX: 400 when invalid JSON:API request body.
Chris@5 1936 $response = $this->request('POST', $url, $request_options);
Chris@5 1937 $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE);
Chris@5 1938
Chris@5 1939 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
Chris@5 1940
Chris@5 1941 // DX: 422 when invalid entity: multiple values sent for single-value field.
Chris@5 1942 $response = $this->request('POST', $url, $request_options);
Chris@5 1943 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
Chris@5 1944 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
Chris@5 1945 $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field);
Chris@5 1946
Chris@5 1947 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
Chris@5 1948
Chris@5 1949 // DX: 403 when invalid entity: UUID field too long.
Chris@5 1950 // @todo Fix this in https://www.drupal.org/project/drupal/issues/2149851.
Chris@5 1951 if ($this->entity->getEntityType()->hasKey('uuid')) {
Chris@5 1952 $response = $this->request('POST', $url, $request_options);
Chris@5 1953 $this->assertResourceErrorResponse(422, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $url, $response);
Chris@5 1954 }
Chris@5 1955
Chris@5 1956 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
Chris@5 1957
Chris@5 1958 // DX: 403 when entity contains field without 'edit' access.
Chris@5 1959 $response = $this->request('POST', $url, $request_options);
Chris@5 1960 $this->assertResourceErrorResponse(403, "The current user is not allowed to POST the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
Chris@5 1961
Chris@5 1962 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
Chris@5 1963
Chris@5 1964 // DX: 422 when request document contains non-existent field.
Chris@5 1965 $response = $this->request('POST', $url, $request_options);
Chris@5 1966 $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
Chris@5 1967
Chris@5 1968 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
Chris@5 1969
Chris@5 1970 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
Chris@5 1971
Chris@5 1972 // DX: 415 when request body in existing but not allowed format.
Chris@5 1973 $response = $this->request('POST', $url, $request_options);
Chris@5 1974 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $url, $response);
Chris@5 1975
Chris@5 1976 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 1977
Chris@5 1978 // 201 for well-formed request.
Chris@5 1979 $response = $this->request('POST', $url, $request_options);
Chris@5 1980 $this->assertResourceResponse(201, FALSE, $response);
Chris@5 1981 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
Chris@5 1982 // If the entity is stored, perform extra checks.
Chris@5 1983 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
Chris@5 1984 $created_entity = $this->entityLoadUnchanged(static::$firstCreatedEntityId);
Chris@5 1985 $uuid = $created_entity->uuid();
Chris@5 1986 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 1987 $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]);
Chris@5 1988 if (static::$resourceTypeIsVersionable) {
Chris@5 1989 assert($created_entity instanceof RevisionableInterface);
Chris@5 1990 $location->setOption('query', ['resourceVersion' => 'id:' . $created_entity->getRevisionId()]);
Chris@5 1991 }
Chris@5 1992 /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
Chris@5 1993 $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location'));
Chris@5 1994
Chris@5 1995 // Assert that the entity was indeed created, and that the response body
Chris@5 1996 // contains the serialized created entity.
Chris@5 1997 $created_entity_document = $this->normalize($created_entity, $url);
Chris@5 1998 $decoded_response_body = Json::decode((string) $response->getBody());
Chris@5 1999 $this->assertSame($created_entity_document, $decoded_response_body);
Chris@5 2000 // Assert that the entity was indeed created using the POSTed values.
Chris@5 2001 foreach ($this->getPostDocument()['data']['attributes'] as $field_name => $field_normalization) {
Chris@5 2002 // If the value is an array of properties, only verify that the sent
Chris@5 2003 // properties are present, the server could be computing additional
Chris@5 2004 // properties.
Chris@5 2005 if (is_array($field_normalization)) {
Chris@5 2006 $this->assertArraySubset($field_normalization, $created_entity_document['data']['attributes'][$field_name]);
Chris@5 2007 }
Chris@5 2008 else {
Chris@5 2009 $this->assertSame($field_normalization, $created_entity_document['data']['attributes'][$field_name]);
Chris@5 2010 }
Chris@5 2011 }
Chris@5 2012 if (isset($this->getPostDocument()['data']['relationships'])) {
Chris@5 2013 foreach ($this->getPostDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
Chris@5 2014 // POSTing relationships: 'data' is required, 'links' is optional.
Chris@5 2015 static::recursiveKsort($relationship_field_normalization);
Chris@5 2016 static::recursiveKsort($created_entity_document['data']['relationships'][$field_name]);
Chris@5 2017 $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], ['links' => TRUE]));
Chris@5 2018 }
Chris@5 2019 }
Chris@5 2020 }
Chris@5 2021 else {
Chris@5 2022 $this->assertFalse($response->hasHeader('Location'));
Chris@5 2023 }
Chris@5 2024
Chris@5 2025 // 201 for well-formed request that creates another entity.
Chris@5 2026 // If the entity is stored, delete the first created entity (in case there
Chris@5 2027 // is a uniqueness constraint).
Chris@5 2028 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
Chris@5 2029 $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
Chris@5 2030 }
Chris@5 2031 $response = $this->request('POST', $url, $request_options);
Chris@5 2032 $this->assertResourceResponse(201, FALSE, $response);
Chris@5 2033 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
Chris@5 2034
Chris@5 2035 if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
Chris@5 2036 $second_created_entity = $this->entityStorage->load(static::$secondCreatedEntityId);
Chris@5 2037 $uuid = $second_created_entity->uuid();
Chris@5 2038 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 2039 $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]);
Chris@5 2040 /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
Chris@5 2041 if (static::$resourceTypeIsVersionable) {
Chris@5 2042 assert($created_entity instanceof RevisionableInterface);
Chris@5 2043 $location->setOption('query', ['resourceVersion' => 'id:' . $second_created_entity->getRevisionId()]);
Chris@5 2044 }
Chris@5 2045 $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location'));
Chris@5 2046
Chris@5 2047 // 500 when creating an entity with a duplicate UUID.
Chris@5 2048 $doc = $this->getModifiedEntityForPostTesting();
Chris@5 2049 $doc['data']['id'] = $uuid;
Chris@5 2050 $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]];
Chris@5 2051 $request_options[RequestOptions::BODY] = Json::encode($doc);
Chris@5 2052
Chris@5 2053 $response = $this->request('POST', $url, $request_options);
Chris@5 2054 $this->assertResourceErrorResponse(409, 'Conflict: Entity already exists.', $url, $response, FALSE);
Chris@5 2055
Chris@5 2056 // 201 when successfully creating an entity with a new UUID.
Chris@5 2057 $doc = $this->getModifiedEntityForPostTesting();
Chris@5 2058 $new_uuid = \Drupal::service('uuid')->generate();
Chris@5 2059 $doc['data']['id'] = $new_uuid;
Chris@5 2060 $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]];
Chris@5 2061 $request_options[RequestOptions::BODY] = Json::encode($doc);
Chris@5 2062
Chris@5 2063 $response = $this->request('POST', $url, $request_options);
Chris@5 2064 $this->assertResourceResponse(201, FALSE, $response);
Chris@5 2065 $entities = $this->entityStorage->loadByProperties([$this->uuidKey => $new_uuid]);
Chris@5 2066 $new_entity = reset($entities);
Chris@5 2067 $this->assertNotNull($new_entity);
Chris@5 2068 $new_entity->delete();
Chris@5 2069 }
Chris@5 2070 else {
Chris@5 2071 $this->assertFalse($response->hasHeader('Location'));
Chris@5 2072 }
Chris@5 2073 }
Chris@5 2074
Chris@5 2075 /**
Chris@5 2076 * Tests PATCHing an individual resource, plus edge cases to ensure good DX.
Chris@5 2077 */
Chris@5 2078 public function testPatchIndividual() {
Chris@5 2079 // @todo Remove this in https://www.drupal.org/node/2300677.
Chris@5 2080 if ($this->entity instanceof ConfigEntityInterface) {
Chris@5 2081 $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
Chris@5 2082 return;
Chris@5 2083 }
Chris@5 2084
Chris@5 2085 $prior_revision_id = (int) $this->entityLoadUnchanged($this->entity->id())->getRevisionId();
Chris@5 2086
Chris@5 2087 // Patch testing requires that another entity of the same type exists.
Chris@5 2088 $this->anotherEntity = $this->createAnotherEntity('dupe');
Chris@5 2089
Chris@5 2090 // Try with all of the following request bodies.
Chris@5 2091 $unparseable_request_body = '!{>}<';
Chris@5 2092 $parseable_valid_request_body = Json::encode($this->getPatchDocument());
Chris@5 2093 /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */
Chris@5 2094 $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label'));
Chris@5 2095 $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPatchDocument()));
Chris@5 2096 // The 'field_rest_test' field does not allow 'view' access, so does not end
Chris@5 2097 // up in the JSON:API document. Even when we explicitly add it to the JSON
Chris@5 2098 // API document that we send in a PATCH request, it is considered invalid.
Chris@5 2099 $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()]]], $this->getPatchDocument()));
Chris@5 2100 $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPatchDocument()));
Chris@5 2101 // It is invalid to PATCH a relationship field under the attributes member.
Chris@5 2102 if ($this->entity instanceof FieldableEntityInterface && $this->entity->hasField('field_jsonapi_test_entity_ref')) {
Chris@5 2103 $parseable_invalid_request_body_5 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_jsonapi_test_entity_ref' => ['target_id' => $this->randomString()]]]], $this->getPostDocument()));
Chris@5 2104 }
Chris@5 2105
Chris@5 2106 // The URL and Guzzle request options that will be used in this test. The
Chris@5 2107 // request options will be modified/expanded throughout this test:
Chris@5 2108 // - to first test all mistakes a developer might make, and assert that the
Chris@5 2109 // error responses provide a good DX
Chris@5 2110 // - to eventually result in a well-formed request that succeeds.
Chris@5 2111 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 2112 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
Chris@5 2113 /* $url = $this->entity->toUrl('jsonapi'); */
Chris@5 2114 $request_options = [];
Chris@5 2115 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 2116 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 2117
Chris@5 2118 // DX: 405 when read-only mode is enabled.
Chris@5 2119 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2120 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response);
Chris@5 2121 $this->assertSame(['GET'], $response->getHeader('Allow'));
Chris@5 2122
Chris@5 2123 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
Chris@5 2124
Chris@5 2125 // DX: 415 when no Content-Type request header.
Chris@5 2126 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2127 $this->assertsame(415, $response->getStatusCode());
Chris@5 2128
Chris@5 2129 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 2130
Chris@5 2131 // DX: 403 when unauthorized.
Chris@5 2132 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2133 $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH');
Chris@5 2134 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
Chris@5 2135
Chris@5 2136 $this->setUpAuthorization('PATCH');
Chris@5 2137
Chris@5 2138 // DX: 400 when no request body.
Chris@5 2139 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2140 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
Chris@5 2141
Chris@5 2142 $request_options[RequestOptions::BODY] = $unparseable_request_body;
Chris@5 2143
Chris@5 2144 // DX: 400 when unparseable request body.
Chris@5 2145 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2146 $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE);
Chris@5 2147
Chris@5 2148 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
Chris@5 2149
Chris@5 2150 // DX: 422 when invalid entity: multiple values sent for single-value field.
Chris@5 2151 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2152 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
Chris@5 2153 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
Chris@5 2154 $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field);
Chris@5 2155
Chris@5 2156 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
Chris@5 2157
Chris@5 2158 // DX: 403 when entity contains field without 'edit' access.
Chris@5 2159 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2160 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
Chris@5 2161
Chris@5 2162 // DX: 403 when entity trying to update an entity's ID field.
Chris@5 2163 $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'id'));
Chris@5 2164 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2165 $id_field_name = $this->entity->getEntityType()->getKey('id');
Chris@5 2166 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ($id_field_name). The entity ID cannot be changed.", $url, $response, "/data/attributes/$id_field_name");
Chris@5 2167
Chris@5 2168 if ($this->entity->getEntityType()->hasKey('uuid')) {
Chris@5 2169 // DX: 400 when entity trying to update an entity's UUID field.
Chris@5 2170 $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'uuid'));
Chris@5 2171 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2172 $this->assertResourceErrorResponse(400, sprintf("The selected entity (%s) does not match the ID in the payload (%s).", $this->entity->uuid(), $this->anotherEntity->uuid()), $url, $response, FALSE);
Chris@5 2173 }
Chris@5 2174
Chris@5 2175 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
Chris@5 2176
Chris@5 2177 // DX: 403 when entity contains field without 'edit' nor 'view' access, even
Chris@5 2178 // when the value for that field matches the current value. This is allowed
Chris@5 2179 // in principle, but leads to information disclosure.
Chris@5 2180 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2181 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
Chris@5 2182
Chris@5 2183 // DX: 403 when sending PATCH request with updated read-only fields.
Chris@5 2184 list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
Chris@5 2185 // Send PATCH request by serializing the modified entity, assert the error
Chris@5 2186 // response, change the modified entity field that caused the error response
Chris@5 2187 // back to its original value, repeat.
Chris@5 2188 foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
Chris@5 2189 $request_options[RequestOptions::BODY] = Json::encode($this->normalize($modified_entity, $url));
Chris@5 2190 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2191 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $url->setAbsolute(), $response, '/data/attributes/' . $patch_protected_field_name);
Chris@5 2192 $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
Chris@5 2193 }
Chris@5 2194
Chris@5 2195 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
Chris@5 2196
Chris@5 2197 // DX: 422 when request document contains non-existent field.
Chris@5 2198 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2199 $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
Chris@5 2200
Chris@5 2201 // DX: 422 when updating a relationship field under attributes.
Chris@5 2202 if (isset($parseable_invalid_request_body_5)) {
Chris@5 2203 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_5;
Chris@5 2204 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2205 $this->assertResourceErrorResponse(422, "The following relationship fields were provided as attributes: [ field_jsonapi_test_entity_ref ]", $url, $response, FALSE);
Chris@5 2206 }
Chris@5 2207
Chris@5 2208 // 200 for well-formed PATCH request that sends all fields (even including
Chris@5 2209 // read-only ones, but with unchanged values).
Chris@5 2210 $valid_request_body = NestedArray::mergeDeep($this->normalize($this->entity, $url), $this->getPatchDocument());
Chris@5 2211 $request_options[RequestOptions::BODY] = Json::encode($valid_request_body);
Chris@5 2212 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2213 $this->assertResourceResponse(200, FALSE, $response);
Chris@5 2214 $updated_entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2215 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
Chris@5 2216 $prior_revision_id = (int) $updated_entity->getRevisionId();
Chris@5 2217
Chris@5 2218 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
Chris@5 2219 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
Chris@5 2220
Chris@5 2221 // DX: 415 when request body in existing but not allowed format.
Chris@5 2222 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2223 $this->assertSame(415, $response->getStatusCode());
Chris@5 2224
Chris@5 2225 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 2226
Chris@5 2227 // 200 for well-formed request.
Chris@5 2228 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2229 $this->assertResourceResponse(200, FALSE, $response);
Chris@5 2230 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
Chris@5 2231 // Assert that the entity was indeed updated, and that the response body
Chris@5 2232 // contains the serialized updated entity.
Chris@5 2233 $updated_entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2234 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
Chris@5 2235 if ($this->entity instanceof RevisionLogInterface) {
Chris@5 2236 if (static::$newRevisionsShouldBeAutomatic) {
Chris@5 2237 $this->assertNotSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime());
Chris@5 2238 }
Chris@5 2239 else {
Chris@5 2240 $this->assertSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime());
Chris@5 2241 }
Chris@5 2242 }
Chris@5 2243 $updated_entity_document = $this->normalize($updated_entity, $url);
Chris@5 2244 $this->assertSame($updated_entity_document, Json::decode((string) $response->getBody()));
Chris@5 2245 $prior_revision_id = (int) $updated_entity->getRevisionId();
Chris@5 2246 // Assert that the entity was indeed created using the PATCHed values.
Chris@5 2247 foreach ($this->getPatchDocument()['data']['attributes'] as $field_name => $field_normalization) {
Chris@5 2248 // If the value is an array of properties, only verify that the sent
Chris@5 2249 // properties are present, the server could be computing additional
Chris@5 2250 // properties.
Chris@5 2251 if (is_array($field_normalization)) {
Chris@5 2252 $this->assertArraySubset($field_normalization, $updated_entity_document['data']['attributes'][$field_name]);
Chris@5 2253 }
Chris@5 2254 else {
Chris@5 2255 $this->assertSame($field_normalization, $updated_entity_document['data']['attributes'][$field_name]);
Chris@5 2256 }
Chris@5 2257 }
Chris@5 2258 if (isset($this->getPatchDocument()['data']['relationships'])) {
Chris@5 2259 foreach ($this->getPatchDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
Chris@5 2260 // POSTing relationships: 'data' is required, 'links' is optional.
Chris@5 2261 static::recursiveKsort($relationship_field_normalization);
Chris@5 2262 static::recursiveKsort($updated_entity_document['data']['relationships'][$field_name]);
Chris@5 2263 $this->assertSame($relationship_field_normalization, array_diff_key($updated_entity_document['data']['relationships'][$field_name], ['links' => TRUE]));
Chris@5 2264 }
Chris@5 2265 }
Chris@5 2266
Chris@5 2267 // Ensure that fields do not get deleted if they're not present in the PATCH
Chris@5 2268 // request. Test this using the configurable field that we added, but which
Chris@5 2269 // is not sent in the PATCH request.
Chris@5 2270 $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
Chris@5 2271
Chris@5 2272 // Multi-value field: remove item 0. Then item 1 becomes item 0.
Chris@5 2273 $doc_multi_value_tests = $this->getPatchDocument();
Chris@5 2274 $doc_multi_value_tests['data']['attributes']['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
Chris@5 2275 $doc_remove_item = $doc_multi_value_tests;
Chris@5 2276 unset($doc_remove_item['data']['attributes']['field_rest_test_multivalue'][0]);
Chris@5 2277 $request_options[RequestOptions::BODY] = Json::encode($doc_remove_item, 'api_json');
Chris@5 2278 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2279 $this->assertResourceResponse(200, FALSE, $response);
Chris@5 2280 $updated_entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2281 $this->assertSame([0 => ['value' => 'Two']], $updated_entity->get('field_rest_test_multivalue')->getValue());
Chris@5 2282 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
Chris@5 2283 $prior_revision_id = (int) $updated_entity->getRevisionId();
Chris@5 2284
Chris@5 2285 // Multi-value field: add one item before the existing one, and one after.
Chris@5 2286 $doc_add_items = $doc_multi_value_tests;
Chris@5 2287 $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => 'Three'];
Chris@5 2288 $request_options[RequestOptions::BODY] = Json::encode($doc_add_items);
Chris@5 2289 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2290 $this->assertResourceResponse(200, FALSE, $response);
Chris@5 2291 $expected_document = [
Chris@5 2292 0 => ['value' => 'One'],
Chris@5 2293 1 => ['value' => 'Two'],
Chris@5 2294 2 => ['value' => 'Three'],
Chris@5 2295 ];
Chris@5 2296 $updated_entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2297 $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue());
Chris@5 2298 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
Chris@5 2299 $prior_revision_id = (int) $updated_entity->getRevisionId();
Chris@5 2300
Chris@5 2301 // Finally, assert that when Content Moderation is installed, a new revision
Chris@5 2302 // is automatically created when PATCHing for entity types that have a
Chris@5 2303 // moderation handler.
Chris@5 2304 // @see \Drupal\content_moderation\Entity\Handler\ModerationHandler::onPresave()
Chris@5 2305 // @see \Drupal\content_moderation\EntityTypeInfo::$moderationHandlers
Chris@5 2306 if ($updated_entity instanceof EntityPublishedInterface) {
Chris@5 2307 $updated_entity->setPublished()->save();
Chris@5 2308 }
Chris@5 2309 $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.');
Chris@5 2310
Chris@5 2311 if (!\Drupal::service('content_moderation.moderation_information')->canModerateEntitiesOfEntityType($this->entity->getEntityType())) {
Chris@5 2312 return;
Chris@5 2313 }
Chris@5 2314
Chris@5 2315 $workflow = $this->createEditorialWorkflow();
Chris@5 2316 $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle());
Chris@5 2317 $workflow->save();
Chris@5 2318 $this->grantPermissionsToTestedRole(['use editorial transition publish']);
Chris@5 2319 $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => '3'];
Chris@5 2320 $request_options[RequestOptions::BODY] = Json::encode($doc_add_items);
Chris@5 2321 $response = $this->request('PATCH', $url, $request_options);
Chris@5 2322 $this->assertResourceResponse(200, FALSE, $response);
Chris@5 2323 $expected_document = [
Chris@5 2324 0 => ['value' => 'One'],
Chris@5 2325 1 => ['value' => 'Two'],
Chris@5 2326 2 => ['value' => '3'],
Chris@5 2327 ];
Chris@5 2328 $updated_entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2329 $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue());
Chris@5 2330 if ($this->entity->getEntityType()->hasHandlerClass('moderation')) {
Chris@5 2331 $this->assertLessThan((int) $updated_entity->getRevisionId(), $prior_revision_id);
Chris@5 2332 }
Chris@5 2333 else {
Chris@5 2334 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId());
Chris@5 2335 }
Chris@5 2336
Chris@5 2337 // Ensure that PATCHing an entity that is not the latest revision is
Chris@5 2338 // unsupported.
Chris@5 2339 if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) {
Chris@5 2340 return;
Chris@5 2341 }
Chris@5 2342 assert($this->entity instanceof RevisionableInterface);
Chris@5 2343
Chris@5 2344 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 2345 $request_options[RequestOptions::BODY] = Json::encode([
Chris@5 2346 'data' => [
Chris@5 2347 'type' => static::$resourceTypeName,
Chris@5 2348 'id' => $this->entity->uuid(),
Chris@5 2349 ],
Chris@5 2350 ]);
Chris@5 2351 $this->setUpAuthorization('PATCH');
Chris@5 2352 $this->grantPermissionsToTestedRole([
Chris@5 2353 'use editorial transition create_new_draft',
Chris@5 2354 'use editorial transition archived_published',
Chris@5 2355 'use editorial transition published',
Chris@5 2356 ]);
Chris@5 2357
Chris@5 2358 // Disallow PATCHing an entity that has a pending revision.
Chris@5 2359 $updated_entity->set('moderation_state', 'draft');
Chris@5 2360 $updated_entity->setNewRevision();
Chris@5 2361 $updated_entity->save();
Chris@5 2362 $actual_response = $this->request('PATCH', $url, $request_options);
Chris@5 2363 $this->assertResourceErrorResponse(400, 'Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/jsonapi/issues/2795279.', $url, $actual_response);
Chris@5 2364
Chris@5 2365 // Allow PATCHing an unpublished default revision.
Chris@5 2366 $updated_entity->set('moderation_state', 'archived');
Chris@5 2367 $updated_entity->setNewRevision();
Chris@5 2368 $updated_entity->save();
Chris@5 2369 $actual_response = $this->request('PATCH', $url, $request_options);
Chris@5 2370 $this->assertSame(200, $actual_response->getStatusCode());
Chris@5 2371
Chris@5 2372 // Allow PATCHing an unpublished default revision. (An entity that
Chris@5 2373 // transitions from archived to draft remains an unpublished default
Chris@5 2374 // revision.)
Chris@5 2375 $updated_entity->set('moderation_state', 'draft');
Chris@5 2376 $updated_entity->setNewRevision();
Chris@5 2377 $updated_entity->save();
Chris@5 2378 $actual_response = $this->request('PATCH', $url, $request_options);
Chris@5 2379 $this->assertSame(200, $actual_response->getStatusCode());
Chris@5 2380
Chris@5 2381 // Allow PATCHing a published default revision.
Chris@5 2382 $updated_entity->set('moderation_state', 'published');
Chris@5 2383 $updated_entity->setNewRevision();
Chris@5 2384 $updated_entity->save();
Chris@5 2385 $actual_response = $this->request('PATCH', $url, $request_options);
Chris@5 2386 $this->assertSame(200, $actual_response->getStatusCode());
Chris@5 2387 }
Chris@5 2388
Chris@5 2389 /**
Chris@5 2390 * Tests DELETEing an individual resource, plus edge cases to ensure good DX.
Chris@5 2391 */
Chris@5 2392 public function testDeleteIndividual() {
Chris@5 2393 // @todo Remove this in https://www.drupal.org/node/2300677.
Chris@5 2394 if ($this->entity instanceof ConfigEntityInterface) {
Chris@5 2395 $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
Chris@5 2396 return;
Chris@5 2397 }
Chris@5 2398
Chris@5 2399 // The URL and Guzzle request options that will be used in this test. The
Chris@5 2400 // request options will be modified/expanded throughout this test:
Chris@5 2401 // - to first test all mistakes a developer might make, and assert that the
Chris@5 2402 // error responses provide a good DX
Chris@5 2403 // - to eventually result in a well-formed request that succeeds.
Chris@5 2404 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 2405 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]);
Chris@5 2406 /* $url = $this->entity->toUrl('jsonapi'); */
Chris@5 2407 $request_options = [];
Chris@5 2408 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 2409 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 2410
Chris@5 2411 // DX: 405 when read-only mode is enabled.
Chris@5 2412 $response = $this->request('DELETE', $url, $request_options);
Chris@5 2413 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response);
Chris@5 2414 $this->assertSame(['GET'], $response->getHeader('Allow'));
Chris@5 2415
Chris@5 2416 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
Chris@5 2417
Chris@5 2418 // DX: 403 when unauthorized.
Chris@5 2419 $response = $this->request('DELETE', $url, $request_options);
Chris@5 2420 $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE');
Chris@5 2421 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response, FALSE);
Chris@5 2422
Chris@5 2423 $this->setUpAuthorization('DELETE');
Chris@5 2424
Chris@5 2425 // 204 for well-formed request.
Chris@5 2426 $response = $this->request('DELETE', $url, $request_options);
Chris@5 2427 $this->assertResourceResponse(204, NULL, $response);
Chris@5 2428
Chris@5 2429 // DX: 404 when non-existent.
Chris@5 2430 $response = $this->request('DELETE', $url, $request_options);
Chris@5 2431 $this->assertSame(404, $response->getStatusCode());
Chris@5 2432 }
Chris@5 2433
Chris@5 2434 /**
Chris@5 2435 * Recursively sorts an array by key.
Chris@5 2436 *
Chris@5 2437 * @param array $array
Chris@5 2438 * An array to sort.
Chris@5 2439 */
Chris@5 2440 protected static function recursiveKsort(array &$array) {
Chris@5 2441 // First, sort the main array.
Chris@5 2442 ksort($array);
Chris@5 2443
Chris@5 2444 // Then check for child arrays.
Chris@5 2445 foreach ($array as $key => &$value) {
Chris@5 2446 if (is_array($value)) {
Chris@5 2447 static::recursiveKsort($value);
Chris@5 2448 }
Chris@5 2449 }
Chris@5 2450 }
Chris@5 2451
Chris@5 2452 /**
Chris@5 2453 * Returns Guzzle request options for authentication.
Chris@5 2454 *
Chris@5 2455 * @return array
Chris@5 2456 * Guzzle request options to use for authentication.
Chris@5 2457 *
Chris@5 2458 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 2459 */
Chris@5 2460 protected function getAuthenticationRequestOptions() {
Chris@5 2461 return [
Chris@5 2462 'headers' => [
Chris@5 2463 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
Chris@5 2464 ],
Chris@5 2465 ];
Chris@5 2466 }
Chris@5 2467
Chris@5 2468 /**
Chris@5 2469 * Clones the given entity and modifies all PATCH-protected fields.
Chris@5 2470 *
Chris@5 2471 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 2472 * The entity being tested and to modify.
Chris@5 2473 *
Chris@5 2474 * @return array
Chris@5 2475 * Contains two items:
Chris@5 2476 * 1. The modified entity object.
Chris@5 2477 * 2. The original field values, keyed by field name.
Chris@5 2478 *
Chris@5 2479 * @internal
Chris@5 2480 */
Chris@5 2481 protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
Chris@5 2482 $modified_entity = clone $entity;
Chris@5 2483 $original_values = [];
Chris@5 2484 foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
Chris@5 2485 $field = $modified_entity->get($field_name);
Chris@5 2486 $original_values[$field_name] = $field->getValue();
Chris@5 2487 switch ($field->getItemDefinition()->getClass()) {
Chris@5 2488 case BooleanItem::class:
Chris@5 2489 // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
Chris@5 2490 // chance of not picking a different value.
Chris@5 2491 $field->value = ((int) $field->value) === 1 ? '0' : '1';
Chris@5 2492 break;
Chris@5 2493
Chris@5 2494 case PathItem::class:
Chris@5 2495 // PathItem::generateSampleValue() doesn't set a PID, which causes
Chris@5 2496 // PathItem::postSave() to fail. Keep the PID (and other properties),
Chris@5 2497 // just modify the alias.
Chris@5 2498 $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
Chris@5 2499 break;
Chris@5 2500
Chris@5 2501 default:
Chris@5 2502 $original_field = clone $field;
Chris@5 2503 while ($field->equals($original_field)) {
Chris@5 2504 $field->generateSampleItems();
Chris@5 2505 }
Chris@5 2506 break;
Chris@5 2507 }
Chris@5 2508 }
Chris@5 2509
Chris@5 2510 return [$modified_entity, $original_values];
Chris@5 2511 }
Chris@5 2512
Chris@5 2513 /**
Chris@5 2514 * Gets the normalized POST entity with random values for its unique fields.
Chris@5 2515 *
Chris@5 2516 * @see ::testPostIndividual
Chris@5 2517 * @see ::getPostDocument
Chris@5 2518 *
Chris@5 2519 * @return array
Chris@5 2520 * An array structure as returned by ::getNormalizedPostEntity().
Chris@5 2521 */
Chris@5 2522 protected function getModifiedEntityForPostTesting() {
Chris@5 2523 $document = $this->getPostDocument();
Chris@5 2524
Chris@5 2525 // Ensure that all the unique fields of the entity type get a new random
Chris@5 2526 // value.
Chris@5 2527 foreach (static::$uniqueFieldNames as $field_name) {
Chris@5 2528 $field_definition = $this->entity->getFieldDefinition($field_name);
Chris@5 2529 $field_type_class = $field_definition->getItemDefinition()->getClass();
Chris@5 2530 $document['data']['attributes'][$field_name] = $field_type_class::generateSampleValue($field_definition);
Chris@5 2531 }
Chris@5 2532
Chris@5 2533 return $document;
Chris@5 2534 }
Chris@5 2535
Chris@5 2536 /**
Chris@5 2537 * Tests sparse field sets.
Chris@5 2538 *
Chris@5 2539 * @param \Drupal\Core\Url $url
Chris@5 2540 * The base URL with which to test includes.
Chris@5 2541 * @param array $request_options
Chris@5 2542 * Request options to apply.
Chris@5 2543 *
Chris@5 2544 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 2545 */
Chris@5 2546 protected function doTestSparseFieldSets(Url $url, array $request_options) {
Chris@5 2547 $field_sets = $this->getSparseFieldSets();
Chris@5 2548 $expected_cacheability = new CacheableMetadata();
Chris@5 2549 foreach ($field_sets as $type => $field_set) {
Chris@5 2550 if ($type === 'all') {
Chris@5 2551 assert($this->getExpectedCacheTags($field_set) === $this->getExpectedCacheTags());
Chris@5 2552 assert($this->getExpectedCacheContexts($field_set) === $this->getExpectedCacheContexts());
Chris@5 2553 }
Chris@5 2554 $query = ['fields[' . static::$resourceTypeName . ']' => implode(',', $field_set)];
Chris@5 2555 $expected_document = $this->getExpectedDocument();
Chris@5 2556 $expected_cacheability->setCacheTags($this->getExpectedCacheTags($field_set));
Chris@5 2557 $expected_cacheability->setCacheContexts($this->getExpectedCacheContexts($field_set));
Chris@5 2558 // This tests sparse field sets on included entities.
Chris@5 2559 if (strpos($type, 'nested') === 0) {
Chris@5 2560 $this->grantPermissionsToTestedRole(['access user profiles']);
Chris@5 2561 $query['fields[user--user]'] = implode(',', $field_set);
Chris@5 2562 $query['include'] = 'uid';
Chris@5 2563 $owner = $this->entity->getOwner();
Chris@5 2564 $owner_resource = static::toResourceIdentifier($owner);
Chris@5 2565 foreach ($field_set as $field_name) {
Chris@5 2566 $owner_resource['attributes'][$field_name] = $this->serializer->normalize($owner->get($field_name)[0]->get('value'), 'api_json');
Chris@5 2567 }
Chris@5 2568 $owner_resource['links']['self']['href'] = static::getResourceLink($owner_resource);
Chris@5 2569 $expected_document['included'] = [$owner_resource];
Chris@5 2570 $expected_cacheability->addCacheableDependency($owner);
Chris@5 2571 $expected_cacheability->addCacheableDependency(static::entityAccess($owner, 'view', $this->account));
Chris@5 2572 }
Chris@5 2573 // Remove fields not in the sparse field set.
Chris@5 2574 foreach (['attributes', 'relationships'] as $member) {
Chris@5 2575 if (!empty($expected_document['data'][$member])) {
Chris@5 2576 $remaining = array_intersect_key(
Chris@5 2577 $expected_document['data'][$member],
Chris@5 2578 array_flip($field_set)
Chris@5 2579 );
Chris@5 2580 if (empty($remaining)) {
Chris@5 2581 unset($expected_document['data'][$member]);
Chris@5 2582 }
Chris@5 2583 else {
Chris@5 2584 $expected_document['data'][$member] = $remaining;
Chris@5 2585 }
Chris@5 2586 }
Chris@5 2587 }
Chris@5 2588 $url->setOption('query', $query);
Chris@5 2589 // 'self' link should include the 'fields' query param.
Chris@5 2590 $expected_document['links']['self']['href'] = $url->setAbsolute()->toString();
Chris@5 2591
Chris@5 2592 $response = $this->request('GET', $url, $request_options);
Chris@5 2593 // Dynamic Page Cache MISS because cache should vary based on the 'field'
Chris@5 2594 // query param. (Or uncacheable if expensive cache context.)
Chris@5 2595 $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
Chris@5 2596 $this->assertResourceResponse(
Chris@5 2597 200,
Chris@5 2598 $expected_document,
Chris@5 2599 $response,
Chris@5 2600 $expected_cacheability->getCacheTags(),
Chris@5 2601 $expected_cacheability->getCacheContexts(),
Chris@5 2602 FALSE,
Chris@5 2603 $dynamic_cache
Chris@5 2604 );
Chris@5 2605 }
Chris@5 2606 // Test Dynamic Page Cache HIT for a query with the same field set (unless
Chris@5 2607 // expensive cache context is present).
Chris@5 2608 $response = $this->request('GET', $url, $request_options);
Chris@5 2609 $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
Chris@5 2610 }
Chris@5 2611
Chris@5 2612 /**
Chris@5 2613 * Tests included resources.
Chris@5 2614 *
Chris@5 2615 * @param \Drupal\Core\Url $url
Chris@5 2616 * The base URL with which to test includes.
Chris@5 2617 * @param array $request_options
Chris@5 2618 * Request options to apply.
Chris@5 2619 *
Chris@5 2620 * @see \GuzzleHttp\ClientInterface::request()
Chris@5 2621 */
Chris@5 2622 protected function doTestIncluded(Url $url, array $request_options) {
Chris@5 2623 $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
Chris@5 2624 // If there are no relationship fields, we can't include anything.
Chris@5 2625 if (empty($relationship_field_names)) {
Chris@5 2626 return;
Chris@5 2627 }
Chris@5 2628
Chris@5 2629 $field_sets = [
Chris@5 2630 'empty' => [],
Chris@5 2631 'all' => $relationship_field_names,
Chris@5 2632 ];
Chris@5 2633 if (count($relationship_field_names) > 1) {
Chris@5 2634 $about_half_the_fields = floor(count($relationship_field_names) / 2);
Chris@5 2635 $field_sets['some'] = array_slice($relationship_field_names, $about_half_the_fields);
Chris@5 2636
Chris@5 2637 $nested_includes = $this->getNestedIncludePaths();
Chris@5 2638 if (!empty($nested_includes) && !in_array($nested_includes, $field_sets)) {
Chris@5 2639 $field_sets['nested'] = $nested_includes;
Chris@5 2640 }
Chris@5 2641 }
Chris@5 2642
Chris@5 2643 foreach ($field_sets as $type => $included_paths) {
Chris@5 2644 $this->grantIncludedPermissions($included_paths);
Chris@5 2645 $query = ['include' => implode(',', $included_paths)];
Chris@5 2646 $url->setOption('query', $query);
Chris@5 2647 $actual_response = $this->request('GET', $url, $request_options);
Chris@5 2648 $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options);
Chris@5 2649 $expected_document = $expected_response->getResponseData();
Chris@5 2650 // Dynamic Page Cache miss because cache should vary based on the
Chris@5 2651 // 'include' query param.
Chris@5 2652 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 2653 // MISS or UNCACHEABLE depends on data. It must not be HIT.
Chris@5 2654 $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? 'UNCACHEABLE' : 'MISS';
Chris@5 2655 $this->assertResourceResponse(
Chris@5 2656 200,
Chris@5 2657 $expected_document,
Chris@5 2658 $actual_response,
Chris@5 2659 $expected_cacheability->getCacheTags(),
Chris@5 2660 $expected_cacheability->getCacheContexts(),
Chris@5 2661 FALSE,
Chris@5 2662 $dynamic_cache
Chris@5 2663 );
Chris@5 2664 }
Chris@5 2665 }
Chris@5 2666
Chris@5 2667 /**
Chris@5 2668 * Tests individual and collection revisions.
Chris@5 2669 */
Chris@5 2670 public function testRevisions() {
Chris@5 2671 if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) {
Chris@5 2672 return;
Chris@5 2673 }
Chris@5 2674 assert($this->entity instanceof RevisionableInterface);
Chris@5 2675
Chris@5 2676 // JSON:API will only support node and media revisions until Drupal core has
Chris@5 2677 // a generic revision access API.
Chris@5 2678 if (!static::$resourceTypeIsVersionable) {
Chris@5 2679 $this->setUpRevisionAuthorization('GET');
Chris@5 2680 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
Chris@5 2681 $url->setOption('query', ['resourceVersion' => 'id:' . $this->entity->getRevisionId()]);
Chris@5 2682 $request_options = [];
Chris@5 2683 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 2684 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 2685 $response = $this->request('GET', $url, $request_options);
Chris@5 2686 $detail = 'JSON:API does not yet support resource versioning for this resource type.';
Chris@5 2687 $detail .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
Chris@5 2688 $detail .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
Chris@5 2689 $expected_cache_contexts = [
Chris@5 2690 'url.path',
Chris@5 2691 'url.query_args:resourceVersion',
Chris@5 2692 'url.site',
Chris@5 2693 ];
Chris@5 2694 $this->assertResourceErrorResponse(501, $detail, $url, $response, FALSE, ['http_response'], $expected_cache_contexts);
Chris@5 2695 return;
Chris@5 2696 }
Chris@5 2697
Chris@5 2698 // Add a field to modify in order to test revisions.
Chris@5 2699 FieldStorageConfig::create([
Chris@5 2700 'entity_type' => static::$entityTypeId,
Chris@5 2701 'field_name' => 'field_revisionable_number',
Chris@5 2702 'type' => 'integer',
Chris@5 2703 ])->setCardinality(1)->save();
Chris@5 2704 FieldConfig::create([
Chris@5 2705 'entity_type' => static::$entityTypeId,
Chris@5 2706 'field_name' => 'field_revisionable_number',
Chris@5 2707 'bundle' => $this->entity->bundle(),
Chris@5 2708 ])->setLabel('Revisionable text field')->setTranslatable(FALSE)->save();
Chris@5 2709
Chris@5 2710 // Reload entity so that it has the new field.
Chris@5 2711 $entity = $this->entityLoadUnchanged($this->entity->id());
Chris@5 2712
Chris@5 2713 // Set up test data.
Chris@5 2714 /* @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
Chris@5 2715 $entity->set('field_revisionable_number', 42);
Chris@5 2716 $entity->save();
Chris@5 2717 $original_revision_id = (int) $entity->getRevisionId();
Chris@5 2718
Chris@5 2719 $entity->set('field_revisionable_number', 99);
Chris@5 2720 $entity->setNewRevision();
Chris@5 2721 $entity->save();
Chris@5 2722 $latest_revision_id = (int) $entity->getRevisionId();
Chris@5 2723
Chris@5 2724 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
Chris@5 2725 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
Chris@5 2726 /* $url = $this->entity->toUrl('jsonapi'); */
Chris@5 2727 $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute();
Chris@5 2728 $relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
Chris@5 2729 $related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
Chris@5 2730 $original_revision_id_url = clone $url;
Chris@5 2731 $original_revision_id_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
Chris@5 2732 $original_revision_id_relationship_url = clone $relationship_url;
Chris@5 2733 $original_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
Chris@5 2734 $original_revision_id_related_url = clone $related_url;
Chris@5 2735 $original_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
Chris@5 2736 $latest_revision_id_url = clone $url;
Chris@5 2737 $latest_revision_id_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
Chris@5 2738 $latest_revision_id_relationship_url = clone $relationship_url;
Chris@5 2739 $latest_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
Chris@5 2740 $latest_revision_id_related_url = clone $related_url;
Chris@5 2741 $latest_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
Chris@5 2742 $rel_latest_version_url = clone $url;
Chris@5 2743 $rel_latest_version_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
Chris@5 2744 $rel_latest_version_relationship_url = clone $relationship_url;
Chris@5 2745 $rel_latest_version_relationship_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
Chris@5 2746 $rel_latest_version_related_url = clone $related_url;
Chris@5 2747 $rel_latest_version_related_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
Chris@5 2748 $rel_latest_version_collection_url = clone $collection_url;
Chris@5 2749 $rel_latest_version_collection_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
Chris@5 2750 $rel_working_copy_url = clone $url;
Chris@5 2751 $rel_working_copy_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
Chris@5 2752 $rel_working_copy_relationship_url = clone $relationship_url;
Chris@5 2753 $rel_working_copy_relationship_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
Chris@5 2754 $rel_working_copy_related_url = clone $related_url;
Chris@5 2755 $rel_working_copy_related_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
Chris@5 2756 $rel_working_copy_collection_url = clone $collection_url;
Chris@5 2757 $rel_working_copy_collection_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
Chris@5 2758 $rel_invalid_collection_url = clone $collection_url;
Chris@5 2759 $rel_invalid_collection_url->setOption('query', ['resourceVersion' => 'rel:invalid']);
Chris@5 2760 $revision_id_key = 'drupal_internal__' . $this->entity->getEntityType()->getKey('revision');
Chris@5 2761 $published_key = $this->entity->getEntityType()->getKey('published');
Chris@5 2762 $revision_translation_affected_key = $this->entity->getEntityType()->getKey('revision_translation_affected');
Chris@5 2763
Chris@5 2764 $amend_relationship_urls = function (array &$document, $revision_id) {
Chris@5 2765 if (!empty($document['data']['relationships'])) {
Chris@5 2766 foreach ($document['data']['relationships'] as &$relationship) {
Chris@5 2767 $pattern = '/resourceVersion=id%3A\d/';
Chris@5 2768 $replacement = 'resourceVersion=' . urlencode("id:$revision_id");
Chris@5 2769 $relationship['links']['self']['href'] = preg_replace($pattern, $replacement, $relationship['links']['self']['href']);
Chris@5 2770 $relationship['links']['related']['href'] = preg_replace($pattern, $replacement, $relationship['links']['related']['href']);
Chris@5 2771 }
Chris@5 2772 }
Chris@5 2773 };
Chris@5 2774
Chris@5 2775 $request_options = [];
Chris@5 2776 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
Chris@5 2777 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
Chris@5 2778
Chris@5 2779 // Ensure 403 forbidden on typical GET.
Chris@5 2780 $actual_response = $this->request('GET', $url, $request_options);
Chris@5 2781 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
Chris@5 2782 $result = $entity->access('view', $this->account, TRUE);
Chris@5 2783 $detail = 'The current user is not allowed to GET the selected resource.';
Chris@5 2784 if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
Chris@5 2785 $detail .= ' ' . $reason;
Chris@5 2786 }
Chris@5 2787 $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2788
Chris@5 2789 // Ensure that targeting a revision does not bypass access.
Chris@5 2790 $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
Chris@5 2791 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
Chris@5 2792 $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
Chris@5 2793 if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
Chris@5 2794 $detail .= ' ' . $reason;
Chris@5 2795 }
Chris@5 2796 $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2797
Chris@5 2798 $this->setUpRevisionAuthorization('GET');
Chris@5 2799
Chris@5 2800 // Ensure that the URL without a `resourceVersion` query parameter returns
Chris@5 2801 // the default revision. This is always the latest revision when
Chris@5 2802 // content_moderation is not installed.
Chris@5 2803 $actual_response = $this->request('GET', $url, $request_options);
Chris@5 2804 $expected_document = $this->getExpectedDocument();
Chris@5 2805 // The resource object should always links to the specific revision it
Chris@5 2806 // represents.
Chris@5 2807 $expected_document['data']['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString();
Chris@5 2808 $amend_relationship_urls($expected_document, $latest_revision_id);
Chris@5 2809 // Resource objects always link to their specific revision by revision ID.
Chris@5 2810 $expected_document['data']['attributes'][$revision_id_key] = $latest_revision_id;
Chris@5 2811 $expected_document['data']['attributes']['field_revisionable_number'] = 99;
Chris@5 2812 $expected_cache_tags = $this->getExpectedCacheTags();
Chris@5 2813 $expected_cache_contexts = $this->getExpectedCacheContexts();
Chris@5 2814 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2815 // Fetch the same revision using its revision ID.
Chris@5 2816 $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
Chris@5 2817 // The top-level document object's `self` link should always link to the
Chris@5 2818 // request URL.
Chris@5 2819 $expected_document['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString();
Chris@5 2820 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2821 // Ensure dynamic cache HIT on second request when using a version
Chris@5 2822 // negotiator.
Chris@5 2823 $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
Chris@5 2824 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'HIT');
Chris@5 2825 // Fetch the same revision using the `latest-version` link relation type
Chris@5 2826 // negotiator. Without content_moderation, this is always the most recent
Chris@5 2827 // revision.
Chris@5 2828 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
Chris@5 2829 $expected_document['links']['self']['href'] = $rel_latest_version_url->setAbsolute()->toString();
Chris@5 2830 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2831 // Fetch the same revision using the `working-copy` link relation type
Chris@5 2832 // negotiator. Without content_moderation, this is always the most recent
Chris@5 2833 // revision.
Chris@5 2834 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
Chris@5 2835 $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString();
Chris@5 2836 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2837
Chris@5 2838 // Fetch the prior revision.
Chris@5 2839 $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
Chris@5 2840 $expected_document['data']['attributes'][$revision_id_key] = $original_revision_id;
Chris@5 2841 $expected_document['data']['attributes']['field_revisionable_number'] = 42;
Chris@5 2842 $expected_document['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString();
Chris@5 2843 // The resource object should always links to the specific revision it
Chris@5 2844 // represents.
Chris@5 2845 $expected_document['data']['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString();
Chris@5 2846 $amend_relationship_urls($expected_document, $original_revision_id);
Chris@5 2847 // When the resource object is not the latest version or the working copy,
Chris@5 2848 // a link should be provided that links to those versions. Therefore, the
Chris@5 2849 // presence or absence of these links communicates the state of the resource
Chris@5 2850 // object.
Chris@5 2851 $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString();
Chris@5 2852 $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString();
Chris@5 2853 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2854
Chris@5 2855 // Install content_moderation module.
Chris@5 2856 $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.');
Chris@5 2857
Chris@5 2858 // Set up an editorial workflow.
Chris@5 2859 $workflow = $this->createEditorialWorkflow();
Chris@5 2860 $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle());
Chris@5 2861 $workflow->save();
Chris@5 2862
Chris@5 2863 // Ensure the test entity has content_moderation fields attached to it.
Chris@5 2864 /* @var \Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\Entity\TranslatableRevisionableInterface $entity */
Chris@5 2865 $entity = $this->entityStorage->load($entity->id());
Chris@5 2866
Chris@5 2867 // Set the published moderation state on the test entity.
Chris@5 2868 $entity->set('moderation_state', 'published');
Chris@5 2869 $entity->setNewRevision();
Chris@5 2870 $entity->save();
Chris@5 2871 $default_revision_id = (int) $entity->getRevisionId();
Chris@5 2872
Chris@5 2873 // Fetch the published revision by using the `rel` version negotiator and
Chris@5 2874 // the `latest-version` version argument. With content_moderation, this is
Chris@5 2875 // now the most recent revision where the moderation state was the 'default'
Chris@5 2876 // one.
Chris@5 2877 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
Chris@5 2878 $expected_document['data']['attributes'][$revision_id_key] = $default_revision_id;
Chris@5 2879 $expected_document['data']['attributes']['moderation_state'] = 'published';
Chris@5 2880 $expected_document['data']['attributes'][$published_key] = TRUE;
Chris@5 2881 $expected_document['data']['attributes']['field_revisionable_number'] = 99;
Chris@5 2882 $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
Chris@5 2883 $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
Chris@5 2884 // The resource object now must link to the new revision.
Chris@5 2885 $default_revision_id_url = clone $url;
Chris@5 2886 $default_revision_id_url = $default_revision_id_url->setOption('query', ['resourceVersion' => "id:$default_revision_id"]);
Chris@5 2887 $expected_document['data']['links']['self']['href'] = $default_revision_id_url->setAbsolute()->toString();
Chris@5 2888 $amend_relationship_urls($expected_document, $default_revision_id);
Chris@5 2889 // Since the requested version is the latest version and working copy, there
Chris@5 2890 // should be no links.
Chris@5 2891 unset($expected_document['data']['links']['latest-version']);
Chris@5 2892 unset($expected_document['data']['links']['working-copy']);
Chris@5 2893 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2894 // Fetch the collection URL using the `latest-version` version argument.
Chris@5 2895 $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
Chris@5 2896 $expected_response = $this->getExpectedCollectionResponse([$entity], $rel_latest_version_collection_url->toString(), $request_options);
Chris@5 2897 $expected_collection_document = $expected_response->getResponseData();
Chris@5 2898 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 2899 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2900 // Fetch the published revision by using the `working-copy` version
Chris@5 2901 // argument. With content_moderation, this is always the most recent
Chris@5 2902 // revision regardless of moderation state.
Chris@5 2903 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
Chris@5 2904 $expected_document['links']['self']['href'] = $rel_working_copy_url->toString();
Chris@5 2905 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2906 // Fetch the collection URL using the `working-copy` version argument.
Chris@5 2907 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
Chris@5 2908 $expected_collection_document['links']['self']['href'] = $rel_working_copy_collection_url->toString();
Chris@5 2909 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2910 // @todo: remove the next assertion when Drupal core supports entity query access control on revisions.
Chris@5 2911 $rel_working_copy_collection_url_filtered = clone $rel_working_copy_collection_url;
Chris@5 2912 $rel_working_copy_collection_url_filtered->setOption('query', ['filter[foo]' => 'bar'] + $rel_working_copy_collection_url->getOption('query'));
Chris@5 2913 $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options);
Chris@5 2914 $filtered_collection_expected_cache_contexts = [
Chris@5 2915 'url.path',
Chris@5 2916 'url.query_args:filter',
Chris@5 2917 'url.query_args:resourceVersion',
Chris@5 2918 'url.site',
Chris@5 2919 ];
Chris@5 2920 $this->assertResourceErrorResponse(501, 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.', $rel_working_copy_collection_url_filtered, $actual_response, FALSE, ['http_response'], $filtered_collection_expected_cache_contexts);
Chris@5 2921 // Fetch the collection URL using an invalid version identifier.
Chris@5 2922 $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options);
Chris@5 2923 $invalid_version_expected_cache_contexts = [
Chris@5 2924 'url.path',
Chris@5 2925 'url.query_args:resourceVersion',
Chris@5 2926 'url.site',
Chris@5 2927 ];
Chris@5 2928 $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, ['4xx-response', 'http_response'], $invalid_version_expected_cache_contexts);
Chris@5 2929
Chris@5 2930 // Move the entity to its draft moderation state.
Chris@5 2931 $entity->set('field_revisionable_number', 42);
Chris@5 2932 // Change a relationship field so revisions can be tested on related and
Chris@5 2933 // relationship routes.
Chris@5 2934 $new_user = $this->createUser();
Chris@5 2935 $new_user->save();
Chris@5 2936 $entity->set('field_jsonapi_test_entity_ref', ['target_id' => $new_user->id()]);
Chris@5 2937 $entity->set('moderation_state', 'draft');
Chris@5 2938 $entity->setNewRevision();
Chris@5 2939 $entity->save();
Chris@5 2940 $forward_revision_id = (int) $entity->getRevisionId();
Chris@5 2941
Chris@5 2942 // The `latest-version` link should *still* reference the same revision
Chris@5 2943 // since a draft is not a default revision.
Chris@5 2944 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
Chris@5 2945 $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
Chris@5 2946 // Since the latest version is no longer also the working copy, a
Chris@5 2947 // `working-copy` link is required to indicate that there is a forward
Chris@5 2948 // revision available.
Chris@5 2949 $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString();
Chris@5 2950 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2951 // And the same should be true for collections.
Chris@5 2952 $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
Chris@5 2953 $expected_collection_document['data'][0] = $expected_document['data'];
Chris@5 2954 $expected_collection_document['links']['self']['href'] = $rel_latest_version_collection_url->toString();
Chris@5 2955 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2956 // Ensure that the `latest-version` response is same as the default link,
Chris@5 2957 // aside from the document's `self` link.
Chris@5 2958 $actual_response = $this->request('GET', $url, $request_options);
Chris@5 2959 $expected_document['links']['self']['href'] = $url->toString();
Chris@5 2960 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2961 // And the same should be true for collections.
Chris@5 2962 $actual_response = $this->request('GET', $collection_url, $request_options);
Chris@5 2963 $expected_collection_document['links']['self']['href'] = $collection_url->toString();
Chris@5 2964 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2965 // Now, the `working-copy` link should reference the draft revision. This
Chris@5 2966 // is significant because without content_moderation, the two responses
Chris@5 2967 // would still been the same.
Chris@5 2968 //
Chris@5 2969 // Access is checked before any special permissions are granted. This
Chris@5 2970 // asserts a 403 forbidden if the user is not allowed to see unpublished
Chris@5 2971 // content.
Chris@5 2972 $result = $entity->access('view', $this->account, TRUE);
Chris@5 2973 if (!$result->isAllowed()) {
Chris@5 2974 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
Chris@5 2975 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
Chris@5 2976 $expected_cache_tags = Cache::mergeTags($expected_cacheability->getCacheTags(), $entity->getCacheTags());
Chris@5 2977 $expected_cache_contexts = $expected_cacheability->getCacheContexts();
Chris@5 2978 $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
Chris@5 2979 $message = $result instanceof AccessResultReasonInterface ? trim($detail . ' ' . $result->getReason()) : $detail;
Chris@5 2980 $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 2981 // On the collection URL, we should expect to see the draft omitted from
Chris@5 2982 // the collection.
Chris@5 2983 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
Chris@5 2984 $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options);
Chris@5 2985 $expected_collection_document = $expected_response->getResponseData();
Chris@5 2986 $expected_collection_document['data'] = [];
Chris@5 2987 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 2988 $access_denied_response = static::getAccessDeniedResponse($entity, $result, $url, NULL, $detail)->getResponseData();
Chris@5 2989 static::addOmittedObject($expected_collection_document, static::errorsToOmittedObject($access_denied_response['errors']));
Chris@5 2990 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 2991 }
Chris@5 2992
Chris@5 2993 // Since additional permissions are required to see 'draft' entities,
Chris@5 2994 // grant those permissions.
Chris@5 2995 $this->grantPermissionsToTestedRole($this->getEditorialPermissions());
Chris@5 2996
Chris@5 2997 // Now, the `working-copy` link should be latest revision and be accessible.
Chris@5 2998 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
Chris@5 2999 $expected_document['data']['attributes'][$revision_id_key] = $forward_revision_id;
Chris@5 3000 $expected_document['data']['attributes']['moderation_state'] = 'draft';
Chris@5 3001 $expected_document['data']['attributes'][$published_key] = FALSE;
Chris@5 3002 $expected_document['data']['attributes']['field_revisionable_number'] = 42;
Chris@5 3003 $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString();
Chris@5 3004 $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
Chris@5 3005 // The resource object now must link to the forward revision.
Chris@5 3006 $forward_revision_id_url = clone $url;
Chris@5 3007 $forward_revision_id_url = $forward_revision_id_url->setOption('query', ['resourceVersion' => "id:$forward_revision_id"]);
Chris@5 3008 $expected_document['data']['links']['self']['href'] = $forward_revision_id_url->setAbsolute()->toString();
Chris@5 3009 $amend_relationship_urls($expected_document, $forward_revision_id);
Chris@5 3010 // Since the the working copy is not the default revision. A
Chris@5 3011 // `latest-version` link is required to indicate that the requested version
Chris@5 3012 // is not the default revision.
Chris@5 3013 unset($expected_document['data']['links']['working-copy']);
Chris@5 3014 $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString();
Chris@5 3015 $expected_cache_tags = $this->getExpectedCacheTags();
Chris@5 3016 $expected_cache_contexts = $this->getExpectedCacheContexts();
Chris@5 3017 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
Chris@5 3018 // And the collection response should also have the latest revision.
Chris@5 3019 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
Chris@5 3020 $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options);
Chris@5 3021 $expected_collection_document = $expected_response->getResponseData();
Chris@5 3022 $expected_collection_document['data'] = [$expected_document['data']];
Chris@5 3023 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3024 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 3025
Chris@5 3026 // Test relationship responses.
Chris@5 3027 // Fetch the prior revision's relationship URL.
Chris@5 3028 $test_relationship_urls = [
Chris@5 3029 [
Chris@5 3030 NULL,
Chris@5 3031 $relationship_url,
Chris@5 3032 $related_url,
Chris@5 3033 ],
Chris@5 3034 [
Chris@5 3035 $original_revision_id,
Chris@5 3036 $original_revision_id_relationship_url,
Chris@5 3037 $original_revision_id_related_url,
Chris@5 3038 ],
Chris@5 3039 [
Chris@5 3040 $latest_revision_id,
Chris@5 3041 $latest_revision_id_relationship_url,
Chris@5 3042 $latest_revision_id_related_url,
Chris@5 3043 ],
Chris@5 3044 [
Chris@5 3045 $default_revision_id,
Chris@5 3046 $rel_latest_version_relationship_url,
Chris@5 3047 $rel_latest_version_related_url,
Chris@5 3048 ],
Chris@5 3049 [
Chris@5 3050 $forward_revision_id,
Chris@5 3051 $rel_working_copy_relationship_url,
Chris@5 3052 $rel_working_copy_related_url,
Chris@5 3053 ],
Chris@5 3054 ];
Chris@5 3055 foreach ($test_relationship_urls as $revision_case) {
Chris@5 3056 list($revision_id, $relationship_url, $related_url) = $revision_case;
Chris@5 3057 // Load the revision that will be requested.
Chris@5 3058 $this->entityStorage->resetCache([$entity->id()]);
Chris@5 3059 $revision = is_null($revision_id)
Chris@5 3060 ? $this->entityStorage->load($entity->id())
Chris@5 3061 : $this->entityStorage->loadRevision($revision_id);
Chris@5 3062 // Request the relationship resource without access to the relationship
Chris@5 3063 // field.
Chris@5 3064 $actual_response = $this->request('GET', $relationship_url, $request_options);
Chris@5 3065 $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
Chris@5 3066 $expected_document = $expected_response->getResponseData();
Chris@5 3067 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3068 $expected_document['errors'][0]['links']['via']['href'] = $relationship_url->toString();
Chris@5 3069 $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts());
Chris@5 3070 // Request the related route.
Chris@5 3071 $actual_response = $this->request('GET', $related_url, $request_options);
Chris@5 3072 // @todo: refactor self::getExpectedRelatedResponses() into a function which returns a single response.
Chris@5 3073 $expected_response = $this->getExpectedRelatedResponses(['field_jsonapi_test_entity_ref'], $request_options, $revision)['field_jsonapi_test_entity_ref'];
Chris@5 3074 $expected_document = $expected_response->getResponseData();
Chris@5 3075 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3076 $expected_document['errors'][0]['links']['via']['href'] = $related_url->toString();
Chris@5 3077 $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts());
Chris@5 3078 }
Chris@5 3079 $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']);
Chris@5 3080 foreach ($test_relationship_urls as $revision_case) {
Chris@5 3081 list($revision_id, $relationship_url, $related_url) = $revision_case;
Chris@5 3082 // Load the revision that will be requested.
Chris@5 3083 $this->entityStorage->resetCache([$entity->id()]);
Chris@5 3084 $revision = is_null($revision_id)
Chris@5 3085 ? $this->entityStorage->load($entity->id())
Chris@5 3086 : $this->entityStorage->loadRevision($revision_id);
Chris@5 3087 // Request the relationship resource after granting access to the
Chris@5 3088 // relationship field.
Chris@5 3089 $actual_response = $this->request('GET', $relationship_url, $request_options);
Chris@5 3090 $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
Chris@5 3091 $expected_document = $expected_response->getResponseData();
Chris@5 3092 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3093 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
Chris@5 3094 // Request the related route.
Chris@5 3095 $actual_response = $this->request('GET', $related_url, $request_options);
Chris@5 3096 $expected_response = $this->getExpectedRelatedResponse('field_jsonapi_test_entity_ref', $request_options, $revision);
Chris@5 3097 $expected_document = $expected_response->getResponseData();
Chris@5 3098 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3099 $expected_document['links']['self']['href'] = $related_url->toString();
Chris@5 3100 // MISS or UNCACHEABLE depends on data. It must not be HIT.
Chris@5 3101 $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
Chris@5 3102 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
Chris@5 3103 }
Chris@5 3104
Chris@5 3105 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE);
Chris@5 3106
Chris@5 3107 // Ensures that PATCH and DELETE on individual resources with a
Chris@5 3108 // `resourceVersion` query parameter is not supported.
Chris@5 3109 $individual_urls = [
Chris@5 3110 $original_revision_id_url,
Chris@5 3111 $latest_revision_id_url,
Chris@5 3112 $rel_latest_version_url,
Chris@5 3113 $rel_working_copy_url,
Chris@5 3114 ];
Chris@5 3115 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
Chris@5 3116 foreach ($individual_urls as $url) {
Chris@5 3117 foreach (['PATCH', 'DELETE'] as $method) {
Chris@5 3118 $actual_response = $this->request($method, $url, $request_options);
Chris@5 3119 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
Chris@5 3120 }
Chris@5 3121 }
Chris@5 3122
Chris@5 3123 // Ensures that PATCH, POST and DELETE on relationship resources with a
Chris@5 3124 // `resourceVersion` query parameter is not supported.
Chris@5 3125 $relationship_urls = [
Chris@5 3126 $original_revision_id_relationship_url,
Chris@5 3127 $latest_revision_id_relationship_url,
Chris@5 3128 $rel_latest_version_relationship_url,
Chris@5 3129 $rel_working_copy_relationship_url,
Chris@5 3130 ];
Chris@5 3131 foreach ($relationship_urls as $url) {
Chris@5 3132 foreach (['PATCH', 'POST', 'DELETE'] as $method) {
Chris@5 3133 $actual_response = $this->request($method, $url, $request_options);
Chris@5 3134 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
Chris@5 3135 }
Chris@5 3136 }
Chris@5 3137
Chris@5 3138 // Ensures that POST on collection resources with a `resourceVersion` query
Chris@5 3139 // parameter is not supported.
Chris@5 3140 $collection_urls = [
Chris@5 3141 $rel_latest_version_collection_url,
Chris@5 3142 $rel_working_copy_collection_url,
Chris@5 3143 ];
Chris@5 3144 foreach ($collection_urls as $url) {
Chris@5 3145 foreach (['POST'] as $method) {
Chris@5 3146 $actual_response = $this->request($method, $url, $request_options);
Chris@5 3147 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response);
Chris@5 3148 }
Chris@5 3149 }
Chris@5 3150 }
Chris@5 3151
Chris@5 3152 /**
Chris@5 3153 * Decorates the expected response with included data and cache metadata.
Chris@5 3154 *
Chris@5 3155 * This adds the expected includes to the expected document and also builds
Chris@5 3156 * the expected cacheability for those includes. It does so based of responses
Chris@5 3157 * from the related routes for individual relationships.
Chris@5 3158 *
Chris@5 3159 * @param \Drupal\jsonapi\ResourceResponse $expected_response
Chris@5 3160 * The expected ResourceResponse.
Chris@5 3161 * @param \Drupal\jsonapi\ResourceResponse[] $related_responses
Chris@5 3162 * The related ResourceResponses, keyed by relationship field names.
Chris@5 3163 *
Chris@5 3164 * @return \Drupal\jsonapi\ResourceResponse
Chris@5 3165 * The decorated ResourceResponse.
Chris@5 3166 */
Chris@5 3167 protected static function decorateExpectedResponseForIncludedFields(ResourceResponse $expected_response, array $related_responses) {
Chris@5 3168 $expected_document = $expected_response->getResponseData();
Chris@5 3169 $expected_cacheability = $expected_response->getCacheableMetadata();
Chris@5 3170 foreach ($related_responses as $related_response) {
Chris@5 3171 $related_document = $related_response->getResponseData();
Chris@5 3172 $expected_cacheability->addCacheableDependency($related_response->getCacheableMetadata());
Chris@5 3173 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
Chris@5 3174 // If any of the related response documents had omitted items or errors,
Chris@5 3175 // we should later expect the document to have omitted items as well.
Chris@5 3176 if (!empty($related_document['errors'])) {
Chris@5 3177 static::addOmittedObject($expected_document, static::errorsToOmittedObject($related_document['errors']));
Chris@5 3178 }
Chris@5 3179 if (!empty($related_document['meta']['omitted'])) {
Chris@5 3180 static::addOmittedObject($expected_document, $related_document['meta']['omitted']);
Chris@5 3181 }
Chris@5 3182 if (isset($related_document['data'])) {
Chris@5 3183 $related_data = $related_document['data'];
Chris@5 3184 $related_resources = (static::isResourceIdentifier($related_data))
Chris@5 3185 ? [$related_data]
Chris@5 3186 : $related_data;
Chris@5 3187 foreach ($related_resources as $related_resource) {
Chris@5 3188 if (empty($expected_document['included']) || !static::collectionHasResourceIdentifier($related_resource, $expected_document['included'])) {
Chris@5 3189 $expected_document['included'][] = $related_resource;
Chris@5 3190 }
Chris@5 3191 }
Chris@5 3192 }
Chris@5 3193 }
Chris@5 3194 return (new ResourceResponse($expected_document))->addCacheableDependency($expected_cacheability);
Chris@5 3195 }
Chris@5 3196
Chris@5 3197 /**
Chris@5 3198 * Gets the expected individual ResourceResponse for GET.
Chris@5 3199 *
Chris@5 3200 * @return \Drupal\jsonapi\ResourceResponse
Chris@5 3201 * The expected individual ResourceResponse.
Chris@5 3202 */
Chris@5 3203 protected function getExpectedGetIndividualResourceResponse($status_code = 200) {
Chris@5 3204 $resource_response = new ResourceResponse($this->getExpectedDocument(), $status_code);
Chris@5 3205 $cacheability = new CacheableMetadata();
Chris@5 3206 $cacheability->setCacheContexts($this->getExpectedCacheContexts());
Chris@5 3207 $cacheability->setCacheTags($this->getExpectedCacheTags());
Chris@5 3208 return $resource_response->addCacheableDependency($cacheability);
Chris@5 3209 }
Chris@5 3210
Chris@5 3211 /**
Chris@5 3212 * Returns an array of sparse fields sets to test.
Chris@5 3213 *
Chris@5 3214 * @return array
Chris@5 3215 * An array of sparse field sets (an array of field names), keyed by a label
Chris@5 3216 * for the field set.
Chris@5 3217 */
Chris@5 3218 protected function getSparseFieldSets() {
Chris@5 3219 $field_names = array_keys($this->entity->toArray());
Chris@5 3220 $field_sets = [
Chris@5 3221 'empty' => [],
Chris@5 3222 'some' => array_slice($field_names, floor(count($field_names) / 2)),
Chris@5 3223 'all' => $field_names,
Chris@5 3224 ];
Chris@5 3225 if ($this->entity instanceof EntityOwnerInterface) {
Chris@5 3226 $field_sets['nested_empty_fieldset'] = $field_sets['empty'];
Chris@5 3227 $field_sets['nested_fieldset_with_owner_fieldset'] = ['name', 'created'];
Chris@5 3228 }
Chris@5 3229 return $field_sets;
Chris@5 3230 }
Chris@5 3231
Chris@5 3232 /**
Chris@5 3233 * Gets a list of public relationship names for the resource type under test.
Chris@5 3234 *
Chris@5 3235 * @param \Drupal\Core\Entity\EntityInterface|null $entity
Chris@5 3236 * (optional) The entity for which to get relationship field names.
Chris@5 3237 *
Chris@5 3238 * @return array
Chris@5 3239 * An array of relationship field names.
Chris@5 3240 */
Chris@5 3241 protected function getRelationshipFieldNames(EntityInterface $entity = NULL) {
Chris@5 3242 $entity = $entity ?: $this->entity;
Chris@5 3243 // Only content entity types can have relationships.
Chris@5 3244 $fields = $entity instanceof ContentEntityInterface
Chris@5 3245 ? iterator_to_array($entity)
Chris@5 3246 : [];
Chris@5 3247 return array_reduce($fields, function ($field_names, $field) {
Chris@5 3248 /* @var \Drupal\Core\Field\FieldItemListInterface $field */
Chris@5 3249 if (static::isReferenceFieldDefinition($field->getFieldDefinition())) {
Chris@5 3250 $field_names[] = $this->resourceType->getPublicName($field->getName());
Chris@5 3251 }
Chris@5 3252 return $field_names;
Chris@5 3253 }, []);
Chris@5 3254 }
Chris@5 3255
Chris@5 3256 /**
Chris@5 3257 * Authorize the user under test with additional permissions to view includes.
Chris@5 3258 *
Chris@5 3259 * @return array
Chris@5 3260 * An array of special permissions to be granted for certain relationship
Chris@5 3261 * paths where the keys are relationships paths and values are an array of
Chris@5 3262 * permissions.
Chris@5 3263 */
Chris@5 3264 protected static function getIncludePermissions() {
Chris@5 3265 return [];
Chris@5 3266 }
Chris@5 3267
Chris@5 3268 /**
Chris@5 3269 * Gets an array of permissions required to view and update any tested entity.
Chris@5 3270 *
Chris@5 3271 * @return string[]
Chris@5 3272 * An array of permission names.
Chris@5 3273 */
Chris@5 3274 protected function getEditorialPermissions() {
Chris@5 3275 return ['view latest version', "view any unpublished content"];
Chris@5 3276 }
Chris@5 3277
Chris@5 3278 /**
Chris@5 3279 * Checks access for the given operation on the given entity.
Chris@5 3280 *
Chris@5 3281 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 3282 * The entity for which to check field access.
Chris@5 3283 * @param string $operation
Chris@5 3284 * The operation for which to check access.
Chris@5 3285 * @param \Drupal\Core\Session\AccountInterface $account
Chris@5 3286 * The account for which to check access.
Chris@5 3287 *
Chris@5 3288 * @return \Drupal\Core\Access\AccessResultInterface
Chris@5 3289 * The AccessResult.
Chris@5 3290 */
Chris@5 3291 protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
Chris@5 3292 // The default entity access control handler assumes that permissions do not
Chris@5 3293 // change during the lifetime of a request and caches access results.
Chris@5 3294 // However, we're changing permissions during a test run and need fresh
Chris@5 3295 // results, so reset the cache.
Chris@5 3296 \Drupal::entityTypeManager()->getAccessControlHandler($entity->getEntityTypeId())->resetCache();
Chris@5 3297 return $entity->access($operation, $account, TRUE);
Chris@5 3298 }
Chris@5 3299
Chris@5 3300 /**
Chris@5 3301 * Checks access for the given field operation on the given entity.
Chris@5 3302 *
Chris@5 3303 * @param \Drupal\Core\Entity\EntityInterface $entity
Chris@5 3304 * The entity for which to check field access.
Chris@5 3305 * @param string $field_name
Chris@5 3306 * The field for which to check access.
Chris@5 3307 * @param string $operation
Chris@5 3308 * The operation for which to check access.
Chris@5 3309 * @param \Drupal\Core\Session\AccountInterface $account
Chris@5 3310 * The account for which to check access.
Chris@5 3311 *
Chris@5 3312 * @return \Drupal\Core\Access\AccessResultInterface
Chris@5 3313 * The AccessResult.
Chris@5 3314 */
Chris@5 3315 protected static function entityFieldAccess(EntityInterface $entity, $field_name, $operation, AccountInterface $account) {
Chris@5 3316 $entity_access = static::entityAccess($entity, $operation === 'edit' ? 'update' : 'view', $account);
Chris@5 3317 $field_access = $entity->{$field_name}->access($operation, $account, TRUE);
Chris@5 3318 return $entity_access->andIf($field_access);
Chris@5 3319 }
Chris@5 3320
Chris@5 3321 /**
Chris@5 3322 * Gets an array of of all nested include paths to be tested.
Chris@5 3323 *
Chris@5 3324 * @param int $depth
Chris@5 3325 * (optional) The maximum depth to which included paths should be nested.
Chris@5 3326 *
Chris@5 3327 * @return array
Chris@5 3328 * An array of nested include paths.
Chris@5 3329 */
Chris@5 3330 protected function getNestedIncludePaths($depth = 3) {
Chris@5 3331 $get_nested_relationship_field_names = function (EntityInterface $entity, $depth, $path = "") use (&$get_nested_relationship_field_names) {
Chris@5 3332 $relationship_field_names = $this->getRelationshipFieldNames($entity);
Chris@5 3333 if ($depth > 0) {
Chris@5 3334 $paths = [];
Chris@5 3335 foreach ($relationship_field_names as $field_name) {
Chris@5 3336 $next = ($path) ? "$path.$field_name" : $field_name;
Chris@5 3337 $internal_field_name = $this->resourceType->getInternalName($field_name);
Chris@5 3338 if ($target_entity = $entity->{$internal_field_name}->entity) {
Chris@5 3339 $deep = $get_nested_relationship_field_names($target_entity, $depth - 1, $next);
Chris@5 3340 $paths = array_merge($paths, $deep);
Chris@5 3341 }
Chris@5 3342 else {
Chris@5 3343 $paths[] = $next;
Chris@5 3344 }
Chris@5 3345 }
Chris@5 3346 return $paths;
Chris@5 3347 }
Chris@5 3348 return array_map(function ($target_name) use ($path) {
Chris@5 3349 return "$path.$target_name";
Chris@5 3350 }, $relationship_field_names);
Chris@5 3351 };
Chris@5 3352 return $get_nested_relationship_field_names($this->entity, $depth);
Chris@5 3353 }
Chris@5 3354
Chris@5 3355 /**
Chris@5 3356 * Determines if a given field definition is a reference field.
Chris@5 3357 *
Chris@5 3358 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
Chris@5 3359 * The field definition to inspect.
Chris@5 3360 *
Chris@5 3361 * @return bool
Chris@5 3362 * TRUE if the field definition is found to be a reference field. FALSE
Chris@5 3363 * otherwise.
Chris@5 3364 */
Chris@5 3365 protected static function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) {
Chris@5 3366 /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
Chris@5 3367 $item_definition = $field_definition->getItemDefinition();
Chris@5 3368 $main_property = $item_definition->getMainPropertyName();
Chris@5 3369 $property_definition = $item_definition->getPropertyDefinition($main_property);
Chris@5 3370 return $property_definition instanceof DataReferenceTargetDefinition;
Chris@5 3371 }
Chris@5 3372
Chris@5 3373 /**
Chris@5 3374 * Grants authorization to view includes.
Chris@5 3375 *
Chris@5 3376 * @param string[] $include_paths
Chris@5 3377 * An array of include paths for which to grant access.
Chris@5 3378 */
Chris@5 3379 protected function grantIncludedPermissions(array $include_paths = []) {
Chris@5 3380 $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($include_paths));
Chris@5 3381 $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
Chris@5 3382 // Always grant access to 'view' the test entity reference field.
Chris@5 3383 $flattened_permissions[] = 'field_jsonapi_test_entity_ref view access';
Chris@5 3384 $this->grantPermissionsToTestedRole($flattened_permissions);
Chris@5 3385 }
Chris@5 3386
Chris@5 3387 /**
Chris@5 3388 * Loads an entity in the test container, ignoring the static cache.
Chris@5 3389 *
Chris@5 3390 * @param int $id
Chris@5 3391 * The entity ID.
Chris@5 3392 *
Chris@5 3393 * @return \Drupal\Core\Entity\EntityInterface|null
Chris@5 3394 * The loaded entity.
Chris@5 3395 *
Chris@5 3396 * @todo Remove this after https://www.drupal.org/project/drupal/issues/3038706 lands.
Chris@5 3397 */
Chris@5 3398 protected function entityLoadUnchanged($id) {
Chris@5 3399 $this->entityStorage->resetCache();
Chris@5 3400 return $this->entityStorage->loadUnchanged($id);
Chris@5 3401 }
Chris@5 3402
Chris@5 3403 }