 jsonapi.api.php                             |    6 +
 src/Normalizer/HttpExceptionNormalizer.php  |    4 +-
 src/ResourceType/ResourceTypeRepository.php |   20 +-
 tests/src/Functional/NodeTest.php           |  268 +++++++
 tests/src/Functional/ResourceTestBase.php   | 1134 +++++++++++++++++++++++++++
 5 files changed, 1420 insertions(+), 12 deletions(-)

diff --git a/jsonapi.api.php b/jsonapi.api.php
index 67d5dfb..15aa396 100644
--- a/jsonapi.api.php
+++ b/jsonapi.api.php
@@ -85,6 +85,7 @@
  * - Custom field normalization is not supported; only normalizers at the
  *   "DataType" plugin level are supported (these are a level below field
  *   types).
+ * - All available authentication mechanisms are allowed.
  *
  * The JSON API module does provide a PHP API to generate a JSON API
  * representation of entities:
@@ -122,5 +123,10 @@
  *
  * @see http://jsonapi.org/faq/#what-is-the-meaning-of-json-apis-version
  *
+ * Tests: subclasses of base test classes may contain BC breaks between minor
+ * releases, to allow minor releases to A) comply better with the JSON API spec,
+ * B) guarantee that all resource types (and therefore entity types) function as
+ * expected, C) update to future versions of the JSON API spec.
+ *
  * @}
  */
diff --git a/src/Normalizer/HttpExceptionNormalizer.php b/src/Normalizer/HttpExceptionNormalizer.php
index 5f6f6be..c6fead8 100644
--- a/src/Normalizer/HttpExceptionNormalizer.php
+++ b/src/Normalizer/HttpExceptionNormalizer.php
@@ -103,8 +103,10 @@ class HttpExceptionNormalizer extends NormalizerBase {
    *
    * @return string
    *   URL pointing to the specific RFC-2616 section.
+   *
+   * @internal
    */
-  protected function getInfoUrl($status_code) {
+  public static function getInfoUrl($status_code) {
     // Depending on the error code we'll return a different URL.
     $url = 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html';
     $sections = [
diff --git a/src/ResourceType/ResourceTypeRepository.php b/src/ResourceType/ResourceTypeRepository.php
index eb8a763..1bf350f 100644
--- a/src/ResourceType/ResourceTypeRepository.php
+++ b/src/ResourceType/ResourceTypeRepository.php
@@ -62,17 +62,15 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    * {@inheritdoc}
    */
   public function all() {
-    if (!$this->all) {
-      $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions());
-      foreach ($entity_type_ids as $entity_type_id) {
-        $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) {
-          return new ResourceType(
-            $entity_type_id,
-            $bundle,
-            $this->entityTypeManager->getDefinition($entity_type_id)->getClass()
-          );
-        }, array_keys($this->bundleManager->getBundleInfo($entity_type_id))));
-      }
+    $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions());
+    foreach ($entity_type_ids as $entity_type_id) {
+      $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) {
+        return new ResourceType(
+          $entity_type_id,
+          $bundle,
+          $this->entityTypeManager->getDefinition($entity_type_id)->getClass()
+        );
+      }, array_keys($this->bundleManager->getBundleInfo($entity_type_id))));
     }
     return $this->all;
   }
diff --git a/tests/src/Functional/NodeTest.php b/tests/src/Functional/NodeTest.php
new file mode 100644
index 0000000..4ec1faf
--- /dev/null
+++ b/tests/src/Functional/NodeTest.php
@@ -0,0 +1,268 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * @group jsonapi
+ */
+class NodeTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'node';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'node--camelids';
+
+  /**
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'revision_timestamp',
+    'revision_uid',
+    'created',
+    'changed',
+    'promote',
+    'sticky',
+    'path',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+        break;
+      case 'PATCH':
+        // Do not grant the 'create url aliases' permission to test the case
+        // when the path field is protected/not accessible, see
+        // \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase
+        // for a positive test.
+        $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!NodeType::load('camelids')) {
+      // Create a "Camelids" node type.
+      NodeType::create([
+        'name' => 'Camelids',
+        'type' => 'camelids',
+      ])->save();
+    }
+
+    // Create a "Llama" node.
+    $node = Node::create(['type' => 'camelids']);
+    $node->setTitle('Llama')
+      ->setOwnerId($this->account->id())
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789)
+      ->setRevisionCreationTime(123456789)
+      ->set('path', '/llama')
+      ->save();
+
+    return $node;
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    $self_url = Url::fromUri('base:/jsonapi/node/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'created' => $this->formatExpectedTimestampItemValues(123456789),
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'nid' => 1,
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'promote' => TRUE,
+          'revision_log' => NULL,
+          'revision_timestamp' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'revision_timestamp' => $this->formatExpectedTimestampItemValues(123456789),
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'status' => TRUE,
+          'sticky' => FALSE,
+          'title' => 'Llama',
+          'uuid' => $this->entity->uuid(),
+          'vid' => 1,
+        ],
+        'relationships' => [
+          'type' => [
+            'data' => [
+              'id' => NodeType::load('camelids')->uuid(),
+              'type' => 'node_type--node_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/type',
+              'self' => $self_url . '/relationships/type',
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'revision_uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user'
+            ],
+            'links' => [
+              'related' => $self_url . '/revision_uid',
+              'self' => $self_url . '/relationships/revision_uid',
+            ]
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node--camelids',
+        'attributes' => [
+          'title' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'access content' permission is required.";
+  }
+
+  /**
+   * Tests PATCHing a node's path with and without 'create url aliases'.
+   *
+   * For a positive test, see the similar test coverage for Term.
+   *
+   * @see \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase::testPatchPath()
+   */
+  public function atestPatchPath() {
+    $this->initAuthentication();
+    $this->provisionEntityResource();
+    $this->setUpAuthorization('GET');
+    $this->setUpAuthorization('PATCH');
+
+    $url = $this->getEntityResourceUrl()->setOption('query', ['_format' => static::$format]);
+
+    // GET node's current normalization.
+    $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
+    $normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
+
+    // @todo In https://www.drupal.org/node/2824851, we will be able to stop
+    //       unsetting these fields from the normalization, because
+    //       EntityResource::patch() will ignore any fields that are sent that
+    //       match the current value (and obviously we're sending the current
+    //       value).
+    $normalization = $this->removeFieldsFromNormalization($normalization, [
+      'revision_timestamp',
+      'revision_uid',
+      'created',
+      'changed',
+      'promote',
+      'sticky',
+    ]);
+
+    // Change node's path alias.
+    $normalization['path'][0]['alias'] .= 's-rule-the-world';
+
+    // Create node PATCH request.
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+    // PATCH request: 403 when creating URL aliases unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'path'.", $response);
+
+    // Grant permission to create URL aliases.
+    $this->grantPermissionsToTestedRole(['create url aliases']);
+
+    // Repeat PATCH request: 200.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $updated_normalization = $this->serializer->decode((string) $response->getBody(), static::$format);
+    $this->assertSame($normalization['path'], $updated_normalization['path']);
+  }
+
+}
diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000..8332cce
--- /dev/null
+++ b/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,1134 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Behat\Mink\Driver\BrowserKitDriver;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityNullStorage;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Subclass this for every JSON API resource type.
+ */
+abstract class ResourceTestBase extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['jsonapi', 'basic_auth'];
+
+  /**
+   * The tested entity type.
+   *
+   * @var string
+   */
+  protected static $entityTypeId = NULL;
+
+  /**
+   * The name of the tested JSON API resource type.
+   *
+   * @var string
+   */
+  protected static $resourceTypeName = NULL;
+
+  /**
+   * The fields that are protected against modification during PATCH requests.
+   *
+   * @var string[]
+   */
+  protected static $patchProtectedFieldNames;
+
+  /**
+   * The entity ID for the first created entity in testPost().
+   *
+   * The default value of 2 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $firstCreatedEntityId = 2;
+
+  /**
+   * The entity ID for the second created entity in testPost().
+   *
+   * The default value of 3 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $secondCreatedEntityId = 3;
+
+  /**
+   * Optionally specify which field is the 'label' field. Some entities specify
+   * a 'label_callback', but not a 'label' entity key. For example: User.
+   *
+   * @see ::getInvalidNormalizedEntityToCreate
+   *
+   * @var string|null
+   */
+  protected static $labelFieldName = NULL;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The account to use for authentication.
+   *
+   * @var null|\Drupal\Core\Session\AccountInterface
+   */
+  protected $account;
+
+  /**
+   * The entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $entityStorage;
+
+  /**
+   * The serializer service.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The Entity-to-JSON-API service.
+   *
+   * @var \Drupal\jsonapi\EntityToJsonApi
+   */
+  protected $entityToJsonApi;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('serializer');
+    $this->entityToJsonApi = $this->container->get('jsonapi.entity.to_jsonapi');
+
+    // Ensure the anonymous user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
+
+    // Ensure the authenticated user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
+
+    // Create an account.
+    $this->account = $this->createUser();
+
+    // Create an entity.
+    $this->entityStorage = $this->container->get('entity_type.manager')
+      ->getStorage(static::$entityTypeId);
+    $this->entity = $this->createEntity();
+    \Drupal::service('router.builder')->rebuild();
+
+    /*
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Add access-protected field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'type' => 'text',
+      ])
+        ->setCardinality(1)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      // Add multi-value field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test_multivalue',
+        'type' => 'string',
+      ])
+        ->setCardinality(3)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test_multivalue',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field: multi-value')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      // Reload entity so that it has the new field.
+      $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+      // Some entity types are not stored, hence they cannot be reloaded.
+      if ($reloaded_entity !== NULL) {
+        $this->entity = $reloaded_entity;
+
+        // Set a default value on the fields.
+        $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+        $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
+        $this->entity->save();
+      }
+    }
+    */
+  }
+
+  /**
+   * Creates the entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity to be tested.
+   */
+  abstract protected function createEntity();
+
+  /**
+   * Returns the expected normalization of the entity.
+   *
+   * @see ::createEntity()
+   *
+   * @return array
+   */
+  abstract protected function getExpectedNormalizedEntity();
+
+  /**
+   * Returns the normalized POST entity.
+   *
+   * @see ::testPost
+   *
+   * @return array
+   */
+  abstract protected function getNormalizedPostEntity();
+
+  /**
+   * Returns the normalized PATCH entity.
+   *
+   * By default, reuses ::getNormalizedPostEntity(), which works fine for most
+   * entity types. A counterexample: the 'comment' entity type.
+   *
+   * @see ::testPatch
+   *
+   * @return array
+   */
+  protected function getNormalizedPatchEntity() {
+    return $this->getNormalizedPostEntity();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    return (new CacheableMetadata())
+      ->setCacheTags(['4xx-response', 'http_response'])
+      ->setCacheContexts(['user.permissions']);
+  }
+
+  /**
+   * The expected cache tags for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheTags() {
+    $expected_cache_tags = [
+      'http_response',
+    ];
+    return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
+  }
+
+  /**
+   * The expected cache contexts for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheContexts() {
+    return [
+      // Cache contexts for JSON API URL query parameters.
+      'url.query_args:fields',
+      'url.query_args:filter',
+      'url.query_args:include',
+      'url.query_args:page',
+      'url.query_args:sort',
+      // Drupal defaults.
+      'url.site',
+      'user.permissions',
+    ];
+  }
+
+  /**
+   * Sets up the necessary authorization.
+   *
+   * In case of a test verifying publicly accessible REST resources: grant
+   * permissions to the anonymous user role.
+   *
+   * In case of a test verifying behavior when using a particular authentication
+   * provider: create a user with a particular set of permissions.
+   *
+   * Because of the $method parameter, it's possible to first set up
+   * authentication for only GET, then add POST, et cetera. This then also
+   * allows for verifying a 403 in case of missing authorization.
+   *
+   * @param string $method
+   *   The HTTP method for which to set up authentication.
+   *
+   * @see ::grantPermissionsToAnonymousRole()
+   * @see ::grantPermissionsToAuthenticatedRole()
+   */
+  abstract protected function setUpAuthorization($method);
+
+  /**
+   * Return the expected error message.
+   *
+   * @param string $method
+   *   The HTTP method (GET, POST, PATCH, DELETE).
+   *
+   * @return string
+   *   The error string.
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return sprintf('The current user is not allowed to %s the selected resource.', strtoupper($method));
+  }
+
+  /**
+   * Grants permissions to the authenticated role.
+   *
+   * @param string[] $permissions
+   *   Permissions to grant.
+   */
+  protected function grantPermissionsToTestedRole(array $permissions) {
+    $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
+  }
+
+  /**
+   * Performs a HTTP request. Wraps the Guzzle HTTP client.
+   *
+   * Why wrap the Guzzle HTTP client? Because we want to keep the actual test
+   * code as simple as possible, and hence not require them to specify the
+   * 'http_errors = FALSE' request option, nor do we want them to have to
+   * convert Drupal Url objects to strings.
+   *
+   * We also don't want to follow redirects automatically, to ensure these tests
+   * are able to detect when redirects are added or removed.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   */
+  protected function request($method, Url $url, array $request_options) {
+    $request_options[RequestOptions::HTTP_ERRORS] = FALSE;
+    $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
+    $request_options = $this->decorateWithXdebugCookie($request_options);
+    $client = $this->getSession()->getDriver()->getClient()->getClient();
+    return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
+  }
+
+  /**
+   * Asserts that a resource response has the given status code and body.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string|false $expected_body
+   *   The expected response body. FALSE in case this should not be asserted.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response to assert.
+   * @param string[]|false $expected_cache_tags
+   *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
+   *   header, or FALSE if that header should be absent. Defaults to FALSE.
+   * @param string[]|false $expected_cache_contexts
+   *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
+   *   response header, or FALSE if that header should be absent. Defaults to
+   *   FALSE.
+   * @param string|false $expected_page_cache_header_value
+   *   (optional) The expected X-Drupal-Cache response header value, or FALSE if
+   *   that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
+   *   to FALSE.
+   * @param string|false $expected_dynamic_page_cache_header_value
+   *   (optional) The expected X-Drupal-Dynamic-Cache response header value, or
+   *   FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
+   *   Defaults to FALSE.
+   */
+  protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
+    $this->assertSame($expected_status_code, $response->getStatusCode());
+    if ($expected_status_code === 204) {
+      // DELETE responses should not include a Content-Type header. But Apache
+      // sets it to 'text/html' by default. We also cannot detect the presence
+      // of Apache either here in the CLI. For now having this documented here
+      // is all we can do.
+      // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+      $this->assertSame('', (string) $response->getBody());
+    }
+    else {
+      $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type'));
+      if ($expected_body !== FALSE) {
+        $this->assertSame($expected_body, (string) $response->getBody());
+      }
+    }
+
+    // Expected cache tags: X-Drupal-Cache-Tags header.
+    $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
+    if (is_array($expected_cache_tags)) {
+      $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
+    }
+
+    // Expected cache contexts: X-Drupal-Cache-Contexts header.
+    $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
+    if (is_array($expected_cache_contexts)) {
+      $this->assertSame($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
+    }
+
+    // Expected Page Cache header value: X-Drupal-Cache header.
+    if ($expected_page_cache_header_value !== FALSE) {
+      $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
+      $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    }
+
+    // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
+    if ($expected_dynamic_page_cache_header_value !== FALSE) {
+      $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
+      $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
+    }
+  }
+
+  /**
+   * Asserts that a resource error response has the given message.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string $expected_title.
+   *  …………………………………………………………………………………………………………………………………………………………………………………………………………………
+   * @param string $expected_message
+   *   …………………………………………………………………………………………………………………………………………………………………………………………………………………
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The error response to assert.
+   * @param string[]|false $expected_cache_tags
+   *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
+   *   header, or FALSE if that header should be absent. Defaults to FALSE.
+   * @param string[]|false $expected_cache_contexts
+   *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
+   *   response header, or FALSE if that header should be absent. Defaults to
+   *   FALSE.
+   * @param string|false $expected_page_cache_header_value
+   *   (optional) The expected X-Drupal-Cache response header value, or FALSE if
+   *   that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
+   *   to FALSE.
+   * @param string|false $expected_dynamic_page_cache_header_value
+   *   (optional) The expected X-Drupal-Dynamic-Cache response header value, or
+   *   FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
+   *   Defaults to FALSE.
+   */
+  protected function assertResourceErrorResponse($expected_status_code, $expected_title, $expected_message, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
+    $expected = [
+      'errors' => [
+        [
+          'title' => $expected_title,
+          'status' => $expected_status_code,
+          'detail' => $expected_message,
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl($expected_status_code),
+          ],
+          'code' => 0,
+        ],
+      ],
+    ];
+    $expected_body = ($expected_message !== FALSE) ? $this->serializer->encode($expected, 'api_json') : FALSE;
+    // @todo ………………………………………;;
+    $this->assertResourceResponse($expected_status_code, $expected_body, $response);
+//    $this->assertResourceResponse($expected_status_code, $expected_body, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
+  }
+
+  /**
+   * Adds the Xdebug cookie to the request options.
+   *
+   * @param array $request_options
+   *   The request options.
+   *
+   * @return array
+   *   Request options updated with the Xdebug cookie if present.
+   */
+  protected function decorateWithXdebugCookie(array $request_options) {
+    $session = $this->getSession();
+    $driver = $session->getDriver();
+    if ($driver instanceof BrowserKitDriver) {
+      $client = $driver->getClient();
+      foreach ($client->getCookieJar()->all() as $cookie) {
+        if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) {
+          $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue();
+        }
+        else {
+          $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue();
+        }
+      }
+    }
+    return $request_options;
+  }
+
+  protected function makeNormalizationViolateJsonApiSpec(array $normalization, $key) {
+    unset($normalization['data'][$key]);
+    return $normalization;
+  }
+
+  /**
+   * Makes the given entity normalization invalid.
+   *
+   * @param array $normalization
+   *   An entity normalization.
+   *
+   * @return array
+   *   The updated entity normalization, now invalid.
+   */
+  protected function makeNormalizationInvalid(array $normalization) {
+    // Add a second label to this entity to make it invalid.
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $normalization['data']['attributes'][$label_field] = [
+      0 => $normalization['data']['attributes'][$label_field],
+      1 => 'Second Title',
+    ];
+
+    return $normalization;
+  }
+
+  /**
+   * Tests GETting an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testGetIndividual() {
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('GET');
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('GET', $url, $request_options);
+    $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+    $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS');
+    $this->assertArrayNotHasKey('Link', $response->getHeaders());
+
+    $this->setUpAuthorization('GET');
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'MISS');
+    $head_headers = $response->getHeaders();
+
+    // 200 for well-formed GET request. Page Cache hit because of HEAD request.
+    // Same for Dynamic Page Cache hit.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'HIT');
+    // Assert that Dynamic Page Cache did not store a ResourceResponse object,
+    // which needs serialization after every cache hit. Instead, it should
+    // contain a flattened response. Otherwise performance suffers.
+    // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
+    $cache_items = $this->container->get('database')
+      ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
+        ':pattern' => '%[route]=jsonapi.%',
+      ])
+      ->fetchAllAssoc('cid');
+    $this->assertTrue(count($cache_items) >= 2);
+    $found_cache_redirect = FALSE;
+    $found_cached_200_response = FALSE;
+    $other_cached_responses_are_4xx = TRUE;
+    foreach ($cache_items as $cid => $cache_item) {
+      $cached_data = unserialize($cache_item->data);
+      if (!isset($cached_data['#cache_redirect'])) {
+        $cached_response = $cached_data['#response'];
+        if ($cached_response->getStatusCode() === 200) {
+          $found_cached_200_response = TRUE;
+        }
+        elseif (!$cached_response->isClientError()) {
+          $other_cached_responses_are_4xx = FALSE;
+        }
+        $this->assertNotInstanceOf(ResourceResponse::class, $cached_response);
+        $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
+      }
+      else {
+        $found_cache_redirect = TRUE;
+      }
+    }
+    $this->assertTrue($found_cache_redirect);
+    $this->assertTrue($found_cached_200_response);
+    $this->assertTrue($other_cached_responses_are_4xx);
+
+    // Sort the serialization data first so we can do an identical comparison
+    // for the keys with the array order the same (it needs to match with
+    // identical comparison).
+    $expected = $this->getExpectedNormalizedEntity();
+    static::recursiveKSort($expected);
+    $actual = $this->serializer->decode((string) $response->getBody(), 'api_json');
+    static::recursiveKSort($actual);
+    $this->assertSame($expected, $actual);
+
+    // Not only assert the normalization, also assert deserialization of the
+    // response results in the expected object.
+    $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), 'api_json', [
+      'target_entity' => static::$entityTypeId,
+      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+    ]);
+    // @todo ………………………………………… (deserialization works, but the UUID is set to a random one rather than the received one)
+//    $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+    $get_headers = $response->getHeaders();
+
+    // Verify that the GET and HEAD responses are the same. The only difference
+    // is that there's no body. For this reason the 'Transfer-Encoding' and
+    // 'Vary' headers are also added to the list of headers to ignore, as they
+    // may be added to GET requests, depending on web server configuration. They
+    // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
+    $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary'];
+    $header_cleaner = function ($headers) use ($ignored_headers) {
+      foreach ($headers as $header => $value) {
+        if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
+          unset($headers[$header]);
+        }
+      }
+      return $headers;
+    };
+    $get_headers = $header_cleaner($get_headers);
+    $head_headers = $header_cleaner($head_headers);
+    $this->assertSame($get_headers, $head_headers);
+
+    // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932.
+    /*
+    // BC: serialization_update_8401().
+    // Only run this for fieldable entities. It doesn't make sense for config
+    // entities as config values always use the raw values (as per the config
+    // schema), returned directly from the ConfigEntityNormalizer, which
+    // doesn't deal with fields individually.
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Test the BC settings for timestamp values.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
+      // Rebuild the container so new config is reflected in the addition of the
+      // TimestampItemNormalizer.
+      $this->rebuildAll();
+
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
+
+      // This ensures the BC layer for bc_timestamp_normalizer_unix works as
+      // expected. This method should be using
+      // ::formatExpectedTimestampValue() to generate the timestamp value. This
+      // will take into account the above config setting.
+      $expected = $this->getExpectedNormalizedEntity();
+      // Config entities are not affected.
+      // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
+      static::recursiveKSort($expected);
+      $actual = $this->serializer->decode((string) $response->getBody(), 'api_json');
+      static::recursiveKSort($actual);
+      $this->assertSame($expected, $actual);
+
+      // Reset the config value and rebuild.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
+      $this->rebuildAll();
+    }
+    */
+
+    // DX: 404 when GETting non-existing entity, but HTML response.
+    $random_uuid = \Drupal::service('uuid')->generate();
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $random_uuid]);
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+    // DX: 404 JSON API response if the ?_format query string is present.
+    $url->setOption('query', ['_format' => 'api_json']);
+    $response = $this->request('GET', $url, $request_options);
+    $path = str_replace($random_uuid, '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
+    $this->assertResourceErrorResponse(404, 'Not Found', $message, $response);
+  }
+
+  /**
+   * Tests POSTing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testPostIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
+      return;
+    }
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json');
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json');
+    $parseable_invalid_request_body_missing_type = $this->serializer->encode($this->makeNormalizationViolateJsonApiSpec($this->getNormalizedPostEntity(), 'type'), 'api_json');
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), 'api_json');
+    $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], 'api_json');
+    // @todo …
+//    $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    $request_options = $this->getAuthenticationRequestOptions('POST');
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
+
+    // @todo JSON API doesn't handle any of these edge cases correctly.
+    /*
+    // DX: 415 when no Content-Type request header. HTML response because
+    // missing ?_format query string.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => 'api_json']);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, '…', 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = '';
+
+    // DX: 400 when no request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'df', 'Syntax error', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+*/
+//    if (static::$auth) {
+//      // DX: forgetting authentication: authentication provider-specific error
+//      // response.
+//      $response = $this->request('POST', $url, $request_options);
+//      $this->assertResponseWhenMissingAuthentication('POST', $response);
+//    }
+//
+//    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
+
+    $this->setUpAuthorization('POST');
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type;
+
+    // DX: 400 when invalid JSON API request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Bad Request', 'Resource object must include a "type".', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('POST', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/title',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, json_encode($expected), $response);
+//    $this->assertResourceErrorResponse(422, 'Unprocessable Entity', "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934386 lands.
+    /*
+    // DX: 422 when invalid entity: UUID field too long.
+    // @todo Fix this in https://www.drupal.org/node/2149851.
+    if ($this->entity->getEntityType()->hasKey('uuid')) {
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
+    }
+    */
+//
+//    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+//
+//    // DX: 403 when entity contains field without 'edit' access.
+//    $response = $this->request('POST', $url, $request_options);
+//    $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands.
+    /*
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
+    */
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response, ['http_response', 'node:2'], $this->getExpectedCacheContexts());
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entityStorage->load(static::$firstCreatedEntityId)->uuid()])->setAbsolute(TRUE)->toString();
+    //$location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString();
+    $this->assertSame([$location], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // If the entity is stored, perform extra checks.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      // Assert that the entity was indeed created, and that the response body
+      // contains the serialized created entity.
+      $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
+      $created_entity_normalization = $this->entityToJsonApi->normalize($created_entity);
+      // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
+      // its body unconditionally.
+      if (static::$entityTypeId !== 'taxonomy_term') {
+        $decoded_response_body = $this->serializer->decode((string) $response->getBody(), 'api_json');
+        // @todo Remove the two lines below once https://www.drupal.org/project/jsonapi/issues/2925043 lands.
+        unset($created_entity_normalization['links']);
+        unset($decoded_response_body['links']);
+        $this->assertSame($created_entity_normalization, $decoded_response_body);
+      }
+      // Assert that the entity was indeed created using the POSTed values.
+      foreach ($this->getNormalizedPostEntity()['data']['attributes'] as $field_name => $field_normalization) {
+        $this->assertSame($field_normalization, $created_entity_normalization['data']['attributes'][$field_name]);
+      }
+    }
+  }
+
+
+  /**
+   * Tests PATCHing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testPatchIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
+      return;
+    }
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json');
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json');
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), 'api_json');
+//    $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json');
+    // The 'field_rest_test' field does not allow 'view' access, so does not end
+    // up in the normalization. Even when we explicitly add it the normalization
+    // that we send in the body of a PATCH request, it is considered invalid.
+//    $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], 'api_json');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');;
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+    // DX: 400 when no request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Syntax error', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    if (static::$auth) {
+      // DX: forgetting authentication: authentication provider-specific error
+      // response.
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResponseWhenMissingAuthentication('PATCH', $response);
+    }
+
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
+
+    $this->setUpAuthorization('PATCH');
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('PATCH', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
+    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+    // DX: 403 when entity contains field without 'edit' access.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+
+    // DX: 403 when entity contains field without 'edit' nor 'view' access, even
+    // when the value for that field matches the current value. This is allowed
+    // in principle, but leads to information disclosure.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
+
+    // DX: 403 when sending PATCH request with updated read-only fields.
+    list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
+    // Send PATCH request by serializing the modified entity, assert the error
+    // response, change the modified entity field that caused the error response
+    // back to its original value, repeat.
+    for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
+      $patch_protected_field_name = static::$patchProtectedFieldNames[$i];
+      $request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'.", $response);
+      $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
+    }
+
+    // 200 for well-formed PATCH request that sends all fields (even including
+    // read-only ones, but with unchanged values).
+    $valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
+    $request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+    // Before sending a well-formed request, allow the normalization and
+    // authentication provider edge cases to also be tested.
+    $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
+    $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // Assert that the entity was indeed updated, and that the response body
+    // contains the serialized updated entity.
+    $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+    $updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]);
+    $this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
+    // Assert that the entity was indeed created using the PATCHed values.
+    foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) {
+      // Some top-level keys in the normalization may not be fields on the
+      // entity (for example '_links' and '_embedded' in the HAL normalization).
+      if ($updated_entity->hasField($field_name)) {
+        // Subset, not same, because we can e.g. send just the target_id for the
+        // bundle in a PATCH request; the response will include more properties.
+        $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
+      }
+    }
+    // Ensure that fields do not get deleted if they're not present in the PATCH
+    // request. Test this using the configurable field that we added, but which
+    // is not sent in the PATCH request.
+    $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);
+
+    // Multi-value field: remove item 0. Then item 1 becomes item 0.
+    $normalization_multi_value_tests = $this->getNormalizedPatchEntity();
+    $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
+    $normalization_remove_item = $normalization_multi_value_tests;
+    unset($normalization_remove_item['field_rest_test_multivalue'][0]);
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
+
+    // Multi-value field: add one item before the existing one, and one after.
+    $normalization_add_items = $normalization_multi_value_tests;
+    $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three'];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
+
+    // BC: rest_update_8203().
+    $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+    $this->refreshTestStateAfterRestConfigChange();
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
+
+    $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+  }
+
+  /**
+   * Tests DELETEing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testDeleteIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
+      return;
+    }
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
+
+    $this->setUpAuthorization('DELETE');
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939
+    $this->assertResourceResponse(204, '', $response, ['http_response'], ['user.permissions']);
+  }
+
+  /**
+   * Transforms a normalization: casts all non-string types to strings.
+   *
+   * @param array $normalization
+   *   A normalization to transform.
+   *
+   * @return array
+   *   The transformed normalization.
+   */
+  protected static function castToString(array $normalization) {
+    foreach ($normalization as $key => $value) {
+      if (is_bool($value)) {
+        $normalization[$key] = (string) (int) $value;
+      }
+      elseif (is_int($value) || is_float($value)) {
+        $normalization[$key] = (string) $value;
+      }
+      elseif (is_array($value)) {
+        $normalization[$key] = static::castToString($value);
+      }
+    }
+    return $normalization;
+  }
+
+  /**
+   * Recursively sorts an array by key.
+   *
+   * @param array $array
+   *   An array to sort.
+   *
+   * @return array
+   *   The sorted array.
+   */
+  protected static function recursiveKSort(array &$array) {
+    // First, sort the main array.
+    ksort($array);
+
+    // Then check for child arrays.
+    foreach ($array as $key => &$value) {
+      if (is_array($value)) {
+        static::recursiveKSort($value);
+      }
+    }
+  }
+
+  /**
+   * Returns Guzzle request options for authentication.
+   *
+   * @param string $method
+   *   The HTTP method for this authenticated request.
+   *
+   * @return array
+   *   Guzzle request options to use for authentication.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getAuthenticationRequestOptions($method) {
+    return [
+      'headers' => [
+        'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+      ],
+    ];
+  }
+
+}
\ No newline at end of file
