annotate core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @ 19:fa3358dc1485 tip

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