diff --git a/composer.lock b/composer.lock
index 9ed6128219..32e57ad01a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -3422,6 +3422,72 @@
             ],
             "time": "2016-10-04T09:27:04+00:00"
         },
+        {
+            "name": "justinrainbow/json-schema",
+            "version": "5.2.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/justinrainbow/json-schema.git",
+                "reference": "dcb6e1006bb5fd1e392b4daa68932880f37550d4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/dcb6e1006bb5fd1e392b4daa68932880f37550d4",
+                "reference": "dcb6e1006bb5fd1e392b4daa68932880f37550d4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "~2.2.20",
+                "json-schema/json-schema-test-suite": "1.2.0",
+                "phpunit/phpunit": "^4.8.35"
+            },
+            "bin": [
+                "bin/validate-json"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "JsonSchema\\": "src/JsonSchema/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bruno Prieto Reis",
+                    "email": "bruno.p.reis@gmail.com"
+                },
+                {
+                    "name": "Justin Rainbow",
+                    "email": "justin.rainbow@gmail.com"
+                },
+                {
+                    "name": "Igor Wiedler",
+                    "email": "igor@wiedler.ch"
+                },
+                {
+                    "name": "Robert Schönthal",
+                    "email": "seroscho@googlemail.com"
+                }
+            ],
+            "description": "A library to validate a json schema.",
+            "homepage": "https://github.com/justinrainbow/json-schema",
+            "keywords": [
+                "json",
+                "schema"
+            ],
+            "time": "2019-01-14T23:55:14+00:00"
+        },
         {
             "name": "mikey179/vfsStream",
             "version": "v1.6.5",
diff --git a/core/composer.json b/core/composer.json
index 7964dee573..9321f3596e 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -63,7 +63,8 @@
         "phpspec/prophecy": "^1.7",
         "symfony/css-selector": "^3.4.0",
         "symfony/phpunit-bridge": "^3.4.3",
-        "symfony/debug": "^3.4.0"
+        "symfony/debug": "^3.4.0",
+        "justinrainbow/json-schema": "^5.2"
     },
     "replace": {
         "drupal/action": "self.version",
@@ -128,6 +129,7 @@
         "drupal/history": "self.version",
         "drupal/image": "self.version",
         "drupal/inline_form_errors": "self.version",
+        "drupal/jsonapi": "self.version",
         "drupal/language": "self.version",
         "drupal/layout_builder": "self.version",
         "drupal/layout_discovery": "self.version",
diff --git a/core/modules/jsonapi/jsonapi.api.php b/core/modules/jsonapi/jsonapi.api.php
new file mode 100644
index 0000000000..798404d596
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.api.php
@@ -0,0 +1,369 @@
+<?php
+
+/**
+ * @file
+ * Documentation related to JSON:API.
+ */
+
+use Drupal\Core\Access\AccessResult;
+
+/**
+ * @defgroup jsonapi_architecture JSON:API Architecture
+ * @{
+ *
+ * @section overview Overview
+ * The JSON:API module is a Drupal-centric implementation of the JSON:API
+ * specification. By its own definition, the JSON:API specification "is a
+ * specification for how a client should request that resources be fetched or
+ * modified, and how a server should respond to those requests. [It] is designed
+ * to minimize both the number of requests and the amount of data transmitted
+ * between clients and servers. This efficiency is achieved without compromising
+ * readability, flexibility, or discoverability."
+ *
+ * While "Drupal-centric", the JSON:API module is committed to strict compliance
+ * with the specification. Wherever possible, the module attempts to implement
+ * the specification in a way which is compatible and familiar with the patterns
+ * and concepts inherent to Drupal. However, when "Drupalisms" cannot be
+ * reconciled with the specification, the module will always choose the
+ * implementation most faithful to the specification.
+ *
+ * @see http://jsonapi.org/
+ *
+ *
+ * @section resources Resources
+ * Every unit of data in the specification is a "resource". The specification
+ * defines how a client should interact with a server to fetch and manipulate
+ * these resources.
+ *
+ * The JSON:API module maps every entity type + bundle to a resource type.
+ * Since the specification does not have a concept of resource type inheritance
+ * or composition, the JSON:API module implements different bundles of the same
+ * entity type as *distinct* resource types.
+ *
+ * While it is theoretically possible to expose arbitrary data as resources, the
+ * JSON:API module only exposes resources from (config and content) entities.
+ * This eliminates the need for another abstraction layer in order implement
+ * certain features of the specification.
+ *
+ *
+ * @section relationships Relationships
+ * The specification defines semantics for the "relationships" between
+ * resources. Since the JSON:API module defines every entity type + bundle as a
+ * resource type and does not allow non-entity resources, it is able to use
+ * entity references to automatically define and represent the relationships
+ * between all resources.
+ *
+ *
+ * @section normalizers Normalizers
+ * The JSON:API module reuses as many of Drupal core's Serialization module's
+ * normalizers as possible.
+ *
+ * The JSON:API specification requires special handling for resources
+ * (entities), relationships between those resources (entity references) and
+ * resource IDs (entity UUIDs), it must override some of the Serialization
+ * module's normalizers for entities and fields (most notably, entity
+ * reference fields).
+ *
+ * This means that modules which provide additional field types must implement
+ * normalizers at the "DataType" plugin level. This is a level below "FieldType"
+ * plugins. Normalizers which are not implemented at this level will not be used
+ * by the JSON:API module.
+ *
+ * A benefit of implementing normalizers at this lower level is that they will
+ * work automatically for both the JSON:API module and core's REST module.
+ *
+ *
+ * @section revisions Resource Versioning
+ * The JSON:API module exposes entity revisions in a manner inspired by RFC5829:
+ * Link Relation Types for Simple Version Navigation between Web Resources.
+ *
+ * Revision support is not an official part of the JSON:API specification.
+ * However, a number of "profiles" are being developed (also not officially part
+ * in the spec, but already committed to JSON:API v1.1) to standardize any
+ * custom behaviors that the JSON:API module has developed (all of which are
+ * still specification compliant).
+ *
+ * @see https://github.com/json-api/json-api/pull/1268
+ * @see https://github.com/json-api/json-api/pull/1311
+ * @see https://www.drupal.org/project/jsonapi/issues/2955020
+ *
+ * In doing so, JSON:API module should be maximally compatible with other
+ * systems.
+ *
+ * A "version" in the JSON:API module is any revision that was previously, or is
+ * currently, a default revision. Not all revisions are considered to be a
+ * "version". Revisions that are not marked as a "default" revision are
+ * considered "working copies" since they are not usually publicly available
+ * and are the revisions to which most new work is applied.
+ *
+ * When the Content Moderation module is installed, it is possible that the
+ * most recent default revision is *not* the latest revision.
+ *
+ * Requesting a resource version is done via a URL query parameter. It has the
+ * following form:
+ *
+ * @code
+ *              version-identifier
+ *                    __|__
+ *                   /     \
+ * ?resource_version=foo:bar
+ *                   \_/ \_/
+ *                    |   |
+ *    version-negotiator  |
+ *                version-argument
+ * @endcode
+ *
+ * A version identifier is a string with enough information to load a
+ * particular revision. The version negotiator component names the negotiation
+ * mechanism for loading a revision. Currently, this can be either @code id
+ * @endcode or @code rel @endcode. The @code id @endcode negotiator takes a
+ * version argument which is the desired revision ID. The @code rel @endcode
+ * negotiator takes a version argument which is either the string @code
+ * latest-version @endcode or the string @code working-copy @endcode.
+ *
+ * In future, other negotiators may be developed. For instance, a negotiator
+ * which is UUID, timestamp or workspace based.
+ *
+ * To illustrate how a particular entity revision is requested, imagine a node
+ * that has a "Published" revision and a subsequent "Draft" revision.
+ *
+ * Using JSON:API, one could request the "Published" node by requesting
+ * @code /jsonapi/node/page/{{uuid}}?resource_version=rel:latest-version
+ * @endcode.
+ *
+ * To preview an entity that is still a work-in-progress (i.e. the "Draft"
+ * revision) one could request
+ * @code /jsonapi/node/page/{{uuid}}?resource_version=rel:working-copy
+ * @endcode.
+ *
+ * To request a specific revision ID, one can request
+ * @code /jsonapi/node/page/{{uuid}}?resource_version=id:{{revision_id}}
+ * @endcode.
+ *
+ * It is not yet possible to request a collection of revisions. This is still
+ * under development in issue [#3009588].
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3009588.
+ *
+ * @see https://tools.ietf.org/html/rfc5829
+ *
+ *
+ * @section api API
+ * The JSON:API module provides an HTTP API that adheres to the JSON:API
+ * specification.
+ *
+ * The JSON:API module provides *no PHP API to modify its behavior.* It is
+ * designed to have zero configuration.
+ *
+ * - Adding new resources/resource types is unsupported: all entities/entity
+ *   types are exposed automatically. If you want to expose more data via the
+ *   JSON:API module, the data must be defined as entity. See the "Resources"
+ *   section.
+ * - 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.
+ *
+ *
+ * @section tests Test Coverage
+ * The JSON:API module comes with extensive unit and kernel tests. But most
+ * importantly for end users, it also has comprehensive integration tests. These
+ * integration tests are designed to:
+ *
+ * - ensure a great DX (Developer Experience)
+ * - detect regressions and normalization changes before shipping a release
+ * - guarantee 100% of Drupal core's entity types work as expected
+ *
+ * The integration tests test the same common cases and edge cases using
+ * @code \Drupal\Tests\jsonapi\Functional\ResourceTestBase @endcode, which is a
+ * base class subclassed for every entity type that Drupal core ships with. It
+ * is ensured that 100% of Drupal core's entity types are tested thanks to
+ * @code \Drupal\Tests\jsonapi\Functional\TestCoverageTest @endcode.
+ *
+ * Custom entity type developers can get the same assurances by subclassing it
+ * for their entity types.
+ *
+ *
+ * @section bc Backwards Compatibility
+ * PHP API: there is no PHP API except for three security-related hooks. This
+ * means that this module's implementation details are entirely free to
+ * change at any time.
+ *
+ * Please note, *normalizers are internal implementation details.* While
+ * normalizers are services, they are *not* to be used directly. This is due to
+ * the design of the Symfony Serialization component, not because the JSON:API
+ * module wanted to publicly expose services.
+ *
+ * HTTP API: URLs and JSON response structures are considered part of this
+ * module's public API. However, inconsistencies with the JSON:API specification
+ * will be considered bugs. Fixes which bring the module into compliance with
+ * the specification are *not* guaranteed to be backwards compatible.
+ *
+ * What this means for developing consumers of the HTTP API is that *clients
+ * should be implemented from the specification first and foremost.* This should
+ * mitigate implicit dependencies on implementation details or inconsistencies
+ * with the specification that are specific to this module.
+ *
+ * To help develop compatible clients, every response indicates the version of
+ * the JSON:API specification used under its "jsonapi" key. Future releases
+ * *may* increment the minor version number if the module implements features of
+ * a later specification. Remember that the specification stipulates that future
+ * versions *will* remain backwards compatible as only additions may be
+ * released.
+ *
+ * @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.
+ *
+ * @}
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Controls access when filtering by entity data via JSON:API.
+ *
+ * This module supports filtering by resource object attributes referenced by
+ * relationship fields. For example, a site may add a "Favorite Animal" field
+ * to user entities, which would permit the following filtered query:
+ * @code
+ * /jsonapi/node/article?filter[uid.field_favorite_animal]=llama
+ * @endcode
+ * This query would return articles authored by users whose favorite animal is a
+ * llama. However, the information about a user's favorite animal should not be
+ * available to users without the "access user profiles" permission. The same
+ * must hold true even if that user is referenced as an article's author.
+ * Therefore, access to filter by this data must be restricted so that access
+ * cannot be bypassed via a JSON:API filtered query.
+ *
+ * As a rule, clients should only be able to filter by data that they can
+ * view.
+ *
+ * Conventionally, @code $entity->access('view') @endcode is how entity access
+ * is checked. This call invokes the corresponding hooks. However, these access
+ * checks require an @code $entity @endcode object. This means that they cannot
+ * be called prior to executing a database query.
+ *
+ * In order to safely enable filtering across a relationship, modules
+ * responsible for entity access must do two things:
+ * - Implement this hook (or hook_jsonapi_ENTITY_TYPE_filter_access()) and
+ *   return an array of AccessResults keyed by the named entity subsets below.
+ * - If the AccessResult::allowed() returned by the above hook does not provide
+ *   enough granularity (for example, if access depends on a bundle field value
+ *   of the entity being queried), then hook_query_TAG_alter() must be
+ *   implemented using the 'entity_access' or 'ENTITY_TYPE_access' query tag.
+ *   See node_query_node_access_alter() for an example.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ *   The entity type of the entity to be filtered upon.
+ * @param \Drupal\Core\Session\AccountInterface $account
+ *   The account for which to check access.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface[]
+ *   An array keyed by a constant which identifies a subset of entities. For
+ *   each subset, the value is one of the following access results:
+ *   - AccessResult::allowed() if all entities within the subset (potentially
+ *     narrowed by hook_query_TAG_alter() implementations) are viewable.
+ *   - AccessResult::forbidden() if any entity within the subset is not
+ *     viewable.
+ *   - AccessResult::neutral() if the implementation has no opinion.
+ *   The supported subsets for which an access result may be returned are:
+ *   - JSONAPI_FILTER_AMONG_ALL: all entities of the given type.
+ *   - JSONAPI_FILTER_AMONG_PUBLISHED: all published entities of the given type.
+ *   - JSONAPI_FILTER_AMONG_ENABLED: all enabled entities of the given type.
+ *   - JSONAPI_FILTER_AMONG_OWN: all entities of the given type owned by the
+ *     user for whom access is being checked.
+ *   See the documentation of the above constants for more information about
+ *   each subset.
+ *
+ * @see hook_jsonapi_ENTITY_TYPE_filter_access()
+ */
+function hook_jsonapi_entity_filter_access(\Drupal\Core\Entity\EntityTypeInterface $entity_type, \Drupal\Core\Session\AccountInterface $account) {
+  // For every entity type that has an admin permission, allow access to filter
+  // by all entities of that type to users with that permission.
+  if ($admin_permission = $entity_type->getAdminPermission()) {
+    return ([
+      JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission),
+    ]);
+  }
+}
+
+/**
+ * Controls access to filtering by entity data via JSON:API.
+ *
+ * This is the entity-type-specific variant of
+ * hook_jsonapi_entity_filter_access(). For implementations with logic that is
+ * specific to a single entity type, it is recommended to implement this hook
+ * rather than the generic hook_jsonapi_entity_filter_access() hook, which is
+ * called for every entity type.
+ *
+ * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+ *   The entity type of the entities to be filtered upon.
+ * @param \Drupal\Core\Session\AccountInterface $account
+ *   The account for which to check access.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface[]
+ *   The array of access results, keyed by subset. See
+ *   hook_jsonapi_entity_filter_access() for details.
+ *
+ * @see hook_jsonapi_entity_filter_access()
+ */
+function hook_jsonapi_ENTITY_TYPE_filter_access(\Drupal\Core\Entity\EntityTypeInterface $entity_type, \Drupal\Core\Session\AccountInterface $account) {
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer llamas'),
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view all published llamas'),
+    JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermissions($account, ['view own published llamas', 'view own unpublished llamas'], 'AND'),
+  ]);
+}
+
+/**
+ * Restricts filtering access to the given field.
+ *
+ * Some fields may contain sensitive information. In these cases, modules are
+ * supposed to implement hook_entity_field_access(). However, this hook receives
+ * an optional @code $items @endcode argument and often must return
+ * AccessResult::neutral() when @code $items === NULL @endcode. This is because
+ * access may or may not be allowed based on the field items or based on the
+ * entity on which the field is attached (if the user is the entity owner, for
+ * example).
+ *
+ * Since JSON:API must check field access prior to having a field item list
+ * instance available (access must be checked before a database query is made),
+ * it is not sufficiently secure to check field 'view' access alone.
+ *
+ * This hook exists so that modules which cannot return
+ * AccessResult::forbidden() from hook_entity_field_access() can still secure
+ * JSON:API requests where necessary.
+ *
+ * If a corresponding implementation of hook_entity_field_access() *can* be
+ * forbidden for one or more values of the @code $items @endcode argument, this
+ * hook *MUST* return AccessResult::forbidden().
+ *
+ * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+ *   The field definition of the field to be filtered upon.
+ * @param \Drupal\Core\Session\AccountInterface $account
+ *   The account for which to check access.
+ *
+ * @return \Drupal\Core\Access\AccessResultInterface
+ *   The access result.
+ */
+function hook_jsonapi_entity_field_filter_access(\Drupal\Core\Field\FieldDefinitionInterface $field_definition, \Drupal\Core\Session\AccountInterface $account) {
+  if ($field_definition->getTargetEntityTypeId() === 'node' && $field_definition->getName() === 'field_sensitive_data') {
+    $has_sufficient_access = FALSE;
+    foreach (['administer nodes', 'view all sensitive field data'] as $permission) {
+      $has_sufficient_access = $has_sufficient_access ?: $account->hasPermission($permission);
+    }
+    return AccessResult::forbiddenIf(!$has_sufficient_access)->cachePerPermissions();
+  }
+  return AccessResult::neutral();
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/jsonapi/jsonapi.info.yml b/core/modules/jsonapi/jsonapi.info.yml
new file mode 100644
index 0000000000..a3330909d3
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.info.yml
@@ -0,0 +1,7 @@
+name: JSON:API
+type: module
+description: Provides a JSON:API standards-compliant API for accessing and manipulating Drupal content and configuration entities.
+core: 8.x
+package: Web services
+dependencies:
+  - drupal:serialization
diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module
new file mode 100644
index 0000000000..f1256bfb69
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.module
@@ -0,0 +1,303 @@
+<?php
+
+/**
+ * @file
+ * Module implementation file.
+ */
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Routing\Routes as JsonApiRoutes;
+
+/**
+ * Array key for denoting type-based filtering access.
+ *
+ * Array key for denoting access to filter among all entities of a given type,
+ * regardless of whether they are published or enabled, and regardless of
+ * their owner.
+ *
+ * @see hook_jsonapi_entity_filter_access()
+ * @see hook_jsonapi_ENTITY_TYPE_filter_access()
+ */
+const JSONAPI_FILTER_AMONG_ALL = 'filter_among_all';
+
+/**
+ * Array key for denoting type-based published-only filtering access.
+ *
+ * Array key for denoting access to filter among all published entities of a
+ * given type, regardless of their owner.
+ *
+ * This is used when an entity type has a "published" entity key and there's a
+ * query condition for the value of that equaling 1.
+ *
+ * @see hook_jsonapi_entity_filter_access()
+ * @see hook_jsonapi_ENTITY_TYPE_filter_access()
+ */
+const JSONAPI_FILTER_AMONG_PUBLISHED = 'filter_among_published';
+
+/**
+ * Array key for denoting type-based enabled-only filtering access.
+ *
+ * Array key for denoting access to filter among all enabled entities of a
+ * given type, regardless of their owner.
+ *
+ * This is used when an entity type has a "status" entity key and there's a
+ * query condition for the value of that equaling 1.
+ *
+ * For the User entity type, which does not have a "status" entity key, the
+ * "status" field is used.
+ *
+ * @see hook_jsonapi_entity_filter_access()
+ * @see hook_jsonapi_ENTITY_TYPE_filter_access()
+ */
+const JSONAPI_FILTER_AMONG_ENABLED = 'filter_among_enabled';
+
+/**
+ * Array key for denoting type-based owned-only filtering access.
+ *
+ * Array key for denoting access to filter among all entities of a given type,
+ * regardless of whether they are published or enabled, so long as they are
+ * owned by the user for whom access is being checked.
+ *
+ * When filtering among User entities, this is used when access is being
+ * checked for an authenticated user and there's a query condition
+ * limiting the result set to just that user's entity object.
+ *
+ * When filtering among entities of another type, this is used when all of the
+ * following conditions are met:
+ * - Access is being checked for an authenticated user.
+ * - The entity type has an "owner" entity key.
+ * - There's a filter/query condition for the value equal to the user's ID.
+ *
+ * @see hook_jsonapi_entity_filter_access()
+ * @see hook_jsonapi_ENTITY_TYPE_filter_access()
+ */
+const JSONAPI_FILTER_AMONG_OWN = 'filter_among_own';
+
+/**
+ * Implements hook_help().
+ */
+function jsonapi_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.jsonapi':
+      $output = '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The JSON:API module is a fully compliant implementation of the <a href=":spec">JSON:API Specification</a>. By following shared conventions, you can increase productivity, take advantage of generalized tooling, and focus on what matters: your application. Clients built around JSON:API are able to take advantage of its features such as efficiently caching responses, sometimes eliminating network requests entirely. For more information, see the <a href=":docs">online documentation for the JSON:API module</a>.', [
+        ':spec' => 'http://jsonapi.org',
+        ':docs' => 'https://www.youtube.com/playlist?list=PLZOQ_ZMpYrZsyO-3IstImK1okrpfAjuMZ',
+      ]) . '</p>';
+      $output .= '<dl>';
+      $output .= '<dt>' . t('General') . '</dt>';
+      $output .= '<dd>' . t('JSON:API is a particular implementation of REST that provides conventions for resource relationships, collections, filters, pagination, and sorting, in addition to error handling and full test coverage. These conventions help developers build clients faster and encourages reuse of code.') . '</dd>';
+      $output .= '</dl>';
+
+      return $output;
+  }
+  return NULL;
+}
+
+/**
+ * Implements hook_entity_bundle_create().
+ */
+function jsonapi_entity_bundle_create() {
+  JsonApiRoutes::rebuild();
+}
+
+/**
+ * Implements hook_entity_bundle_delete().
+ */
+function jsonapi_entity_bundle_delete() {
+  JsonApiRoutes::rebuild();
+}
+
+/**
+ * Implements hook_entity_create().
+ */
+function jsonapi_entity_create(EntityInterface $entity) {
+  if (in_array($entity->getEntityTypeId(), ['field_storage_config', 'field_config'])) {
+    // @todo: only do this when relationship fields are updated, not just any field.
+    JsonApiRoutes::rebuild();
+  }
+}
+
+/**
+ * Implements hook_entity_delete().
+ */
+function jsonapi_entity_delete(EntityInterface $entity) {
+  if (in_array($entity->getEntityTypeId(), ['field_storage_config', 'field_config'])) {
+    // @todo: only do this when relationship fields are updated, not just any field.
+    JsonApiRoutes::rebuild();
+  }
+}
+
+/**
+ * Implements hook_jsonapi_entity_filter_access().
+ */
+function jsonapi_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // All core entity types and most or all contrib entity types allow users
+  // with the entity type's administrative permission to view all of the
+  // entities, so enable similarly permissive filtering to those users as well.
+  // A contrib module may override this decision by returning
+  // AccessResult::forbidden() from its implementation of this hook.
+  if ($admin_permission = $entity_type->getAdminPermission()) {
+    return ([
+      JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission),
+    ]);
+  }
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'aggregator_feed'.
+ */
+function jsonapi_jsonapi_aggregator_feed_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\aggregator\FeedAccessControlHandler::checkAccess()
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access news feeds'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'block_content'.
+ */
+function jsonapi_jsonapi_block_content_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (isReusable()), so this does not have to.
+  return ([
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed(),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'comment'.
+ */
+function jsonapi_jsonapi_comment_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (access to the commented entity), so this does not have to.
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer comments'),
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access comments'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'entity_test'.
+ */
+function jsonapi_jsonapi_entity_test_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess()
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'file'.
+ */
+function jsonapi_jsonapi_file_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\file\FileAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (public OR owner), so this does not have to.
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access content'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'media'.
+ */
+function jsonapi_jsonapi_media_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\media\MediaAccessControlHandler::checkAccess()
+  return ([
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'node'.
+ */
+function jsonapi_jsonapi_node_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\node\NodeAccessControlHandler::access()
+  if ($account->hasPermission('bypass node access')) {
+    return ([
+      JSONAPI_FILTER_AMONG_ALL => AccessResult::allowed()->cachePerPermissions(),
+    ]);
+  }
+  if (!$account->hasPermission('access content')) {
+    $forbidden = AccessResult::forbidden("The 'access content' permission is required.")->cachePerPermissions();
+    return ([
+      JSONAPI_FILTER_AMONG_ALL => $forbidden,
+      JSONAPI_FILTER_AMONG_OWN => $forbidden,
+      JSONAPI_FILTER_AMONG_PUBLISHED => $forbidden,
+      // For legacy reasons, the Node entity type has a "status" key, so forbid
+      // this subset as well, even though it has no semantic meaning.
+      JSONAPI_FILTER_AMONG_ENABLED => $forbidden,
+    ]);
+  }
+
+  return ([
+    // @see \Drupal\node\NodeAccessControlHandler::checkAccess()
+    JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own unpublished content'),
+
+    // @see \Drupal\node\NodeGrantDatabaseStorage::access()
+    // Note that:
+    // - This is just for the default grant. Other node access conditions are
+    //   added via the 'node_access' query tag.
+    // - Permissions were checked earlier in this function, so we must vary the
+    //   cache by them.
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed()->cachePerPermissions(),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'shortcut'.
+ */
+function jsonapi_jsonapi_shortcut_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (shortcut_set = shortcut_current_displayed_set()), so this does not have
+  // to.
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer shortcuts')
+      ->orIf(AccessResult::allowedIfHasPermissions($account, ['access shortcuts', 'customize shortcut links'])),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'taxonomy_term'.
+ */
+function jsonapi_jsonapi_taxonomy_term_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess()
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'),
+    JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'user'.
+ */
+function jsonapi_jsonapi_user_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
+  // @see \Drupal\user\UserAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (!isAnonymous()), so this does not have to.
+  return ([
+    JSONAPI_FILTER_AMONG_OWN => AccessResult::allowed(),
+    JSONAPI_FILTER_AMONG_ENABLED => AccessResult::allowedIfHasPermission($account, 'access user profiles'),
+  ]);
+}
+
+/**
+ * Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'workspace'.
+ */
+function jsonapi_jsonapi_workspace_filter_access(EntityTypeInterface $entity_type, $published, $owner, AccountInterface $account) {
+  // @see \Drupal\workspaces\WorkspaceAccessControlHandler::checkAccess()
+  // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
+  // (isDefaultWorkspace()), so this does not have to.
+  return ([
+    JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view any workspace'),
+    JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own workspace'),
+  ]);
+}
diff --git a/core/modules/jsonapi/jsonapi.routing.yml b/core/modules/jsonapi/jsonapi.routing.yml
new file mode 100644
index 0000000000..a58e841d15
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.routing.yml
@@ -0,0 +1,2 @@
+route_callbacks:
+  - '\Drupal\jsonapi\Routing\Routes::routes'
diff --git a/core/modules/jsonapi/jsonapi.services.yml b/core/modules/jsonapi/jsonapi.services.yml
new file mode 100644
index 0000000000..8b4bf3dbf2
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.services.yml
@@ -0,0 +1,237 @@
+parameters:
+  jsonapi.base_path: /jsonapi
+
+services:
+  jsonapi.serializer:
+    class: Drupal\jsonapi\Serializer\Serializer
+    calls:
+      - [setFallbackNormalizer, ['@serializer']]
+    arguments: [{  }, {  }]
+  serializer.normalizer.http_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.unprocessable_entity_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      # This must have a higher priority than the 'serializer.normalizer.http_exception.jsonapi' to take effect.
+      - { name: jsonapi_normalizer, priority: 1 }
+  serializer.normalizer.entity_access_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityAccessDeniedHttpExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      # This must have a higher priority than the 'serializer.normalizer.http_exception.jsonapi' to take effect.
+      - { name: jsonapi_normalizer, priority: 1 }
+  serializer.normalizer.field_item.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldItemNormalizer
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldNormalizer
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.resource_identifier.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ResourceIdentifierNormalizer
+    arguments: ['@entity_field.manager']
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.resource_object.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.content_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
+    arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.config_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
+    arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.jsonapi_document_toplevel.jsonapi:
+    class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+    arguments: ['@entity_type.manager', '@jsonapi.resource_type.repository']
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.link_collection.jsonapi:
+    class: Drupal\jsonapi\Normalizer\LinkCollectionNormalizer
+    tags:
+      - { name: jsonapi_normalizer }
+  serializer.normalizer.entity_reference_field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+    arguments: ['@jsonapi.link_manager']
+    tags:
+      # This must have a higher priority than the 'serializer.normalizer.field.jsonapi' to take effect.
+      - { name: jsonapi_normalizer, priority: 1 }
+  serializer.encoder.jsonapi:
+    class: Drupal\jsonapi\Encoder\JsonEncoder
+    tags:
+      - { name: jsonapi_encoder, format: 'api_json' }
+  jsonapi.resource_type.repository:
+    class: Drupal\jsonapi\ResourceType\ResourceTypeRepository
+    arguments: ['@entity_type.manager', '@entity_type.bundle.info', '@entity_field.manager', '@cache.jsonapi_resource_types']
+  jsonapi.route_enhancer:
+    class: Drupal\jsonapi\Routing\RouteEnhancer
+    tags:
+      - { name: route_enhancer }
+  jsonapi.deserialization.enhancer:
+    class: Drupal\jsonapi\Routing\DeserializationEnhancer
+    calls:
+      - [setContainer, ['@service_container']]
+    tags:
+      - { name: route_enhancer }
+  jsonapi.link_manager:
+    class: Drupal\jsonapi\LinkManager\LinkManager
+    arguments: ['@url_generator']
+  jsonapi.field_resolver:
+    class: Drupal\jsonapi\Context\FieldResolver
+    arguments: ['@entity_type.manager', '@entity_field.manager', '@entity_type.bundle.info', '@jsonapi.resource_type.repository', '@module_handler']
+  jsonapi.include_resolver:
+    class: Drupal\jsonapi\IncludeResolver
+    arguments:
+      - '@entity_type.manager'
+      - '@jsonapi.entity_access_checker'
+  paramconverter.jsonapi.entity_uuid:
+    parent: paramconverter.entity
+    class: Drupal\jsonapi\ParamConverter\EntityUuidConverter
+    tags:
+      # Priority 10, to ensure it runs before @paramconverter.entity.
+      - { name: paramconverter, priority: 10 }
+  paramconverter.jsonapi.resource_type:
+    class: Drupal\jsonapi\ParamConverter\ResourceTypeConverter
+    arguments: ['@jsonapi.resource_type.repository']
+    tags:
+      - { name: paramconverter }
+  jsonapi.exception_subscriber:
+    class: Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@jsonapi.serializer', '%serializer.formats%']
+
+  logger.channel.jsonapi:
+    parent: logger.channel_base
+    arguments: ['jsonapi']
+
+  # Cache.
+  cache.jsonapi_resource_types:
+    class: Drupal\Core\Cache\MemoryCache\MemoryCache
+    # We need this to add this to the Drupal's cache_tags.invalidator service.
+    # This way it can invalidate the data in here based on tags.
+    tags: [{ name: cache.bin }]
+
+  # Route filter.
+  jsonapi.route_filter.format_setter:
+    class: Drupal\jsonapi\Routing\EarlyFormatSetter
+    tags:
+      # Set to a high priority so it runs before content_type_header_matcher
+      # and other filters that might throw exceptions.
+      - { name: route_filter, priority: 100 }
+
+  # Access Control
+  jsonapi.entity_access_checker:
+    class: Drupal\jsonapi\Access\EntityAccessChecker
+    public: false
+    arguments: ['@jsonapi.resource_type.repository', '@router.no_access_checks', '@current_user', '@entity.repository']
+    calls:
+      - [setNodeRevisionAccessCheck, ['@?access_check.node.revision']] # This is only injected when the service is available.
+      - [setMediaRevisionAccessCheck, ['@?access_check.media.revision']] # This is only injected when the service is available.
+      # This is a temporary measure. JSON:API should not need to be aware of the Content Moderation module.
+      - [setLatestRevisionCheck, ['@?access_check.latest_revision']] # This is only injected when the service is available.
+  access_check.jsonapi.relationship_field_access:
+    class: Drupal\jsonapi\Access\RelationshipFieldAccess
+    arguments: ['@jsonapi.entity_access_checker']
+    tags:
+      - { name: access_check, applies_to: _jsonapi_relationship_field_access, needs_incoming_request: TRUE }
+
+  # Controller.
+  jsonapi.entity_resource:
+    class: \Drupal\jsonapi\Controller\EntityResource
+    arguments:
+      - '@entity_type.manager'
+      - '@entity_field.manager'
+      - '@jsonapi.link_manager'
+      - '@jsonapi.resource_type.repository'
+      - '@renderer'
+      - '@entity.repository'
+      - '@jsonapi.include_resolver'
+      - '@jsonapi.entity_access_checker'
+      - '@jsonapi.field_resolver'
+  jsonapi.file_upload:
+    class: Drupal\jsonapi\Controller\FileUpload
+    arguments:
+      - '@current_user'
+      - '@entity_field.manager'
+      - '@jsonapi.file.uploader.field'
+      - '@http_kernel'
+
+  # Event subscribers.
+  jsonapi.custom_query_parameter_names_validator.subscriber:
+    class: Drupal\jsonapi\EventSubscriber\JsonApiRequestValidator
+    tags:
+      - { name: event_subscriber }
+  jsonapi.resource_response.subscriber:
+    class: Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber
+    arguments: ['@jsonapi.serializer']
+    tags:
+      - { name: event_subscriber }
+  jsonapi.resource_response_validator.subscriber:
+    class: Drupal\jsonapi\EventSubscriber\ResourceResponseValidator
+    arguments: ['@jsonapi.serializer', '@logger.channel.jsonapi', '@module_handler', '@app.root']
+    calls:
+      - [setValidator, []]
+    tags:
+      - { name: event_subscriber, priority: 1000 }
+
+  # Revision management.
+  jsonapi.version_negotiator:
+    class: Drupal\jsonapi\Revisions\VersionNegotiator
+    public: false
+    tags:
+      - { name: service_collector, tag: jsonapi_version_negotiator, call: addVersionNegotiator }
+  jsonapi.version_negotiator.default:
+    arguments: ['@entity_type.manager']
+    public: false
+    abstract: true
+  jsonapi.version_negotiator.id:
+    class: Drupal\jsonapi\Revisions\VersionById
+    parent: jsonapi.version_negotiator.default
+    tags:
+      - { name: jsonapi_version_negotiator, negotiator_name: 'id' }
+  jsonapi.version_negotiator.rel:
+    class: Drupal\jsonapi\Revisions\VersionByRel
+    parent: jsonapi.version_negotiator.default
+    tags:
+      - { name: jsonapi_version_negotiator, negotiator_name: 'rel' }
+  jsonapi.resource_version.route_enhancer:
+    class: Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer
+    public: false
+    arguments:
+      - '@jsonapi.version_negotiator'
+    tags:
+      - { name: route_enhancer }
+
+  # Deprecated services.
+  serializer.normalizer.htt_exception.jsonapi:
+    alias: serializer.normalizer.http_exception.jsonapi
+    deprecated: The "%service_id%" service is deprecated. You should use the 'serializer.normalizer.http_exception.jsonapi' service instead.
+
+  # Forward compatibility.
+  # @todo Remove in Drupal 8.6 (assuming it contains https://www.drupal.org/project/drupal/issues/2926508).
+  serializer.normalizer.timestamp.jsonapi:
+    class: \Drupal\jsonapi\ForwardCompatibility\Normalizer\TimestampNormalizer
+    tags:
+      # Priority must be higher than serializer.normalizer.primitive_data.
+      - { name: normalizer, priority: 20, bc: bc_timestamp_normalizer_unix, bc_config_name: 'serialization.settings' }
+  serializer.normalizer.datetimeiso8601.jsonapi:
+    class: \Drupal\jsonapi\ForwardCompatibility\Normalizer\DateTimeIso8601Normalizer
+    tags:
+      # Priority must be higher than serializer.normalizer.primitive_data.
+      - { name: normalizer, priority: 20 }
+  # @todo Remove in Drupal 8.7 (assuming it contains https://www.drupal.org/project/drupal/issues/2940383)
+  jsonapi.file.uploader.field:
+    class: Drupal\jsonapi\ForwardCompatibility\FileFieldUploader
+    public: false
+    arguments: ['@logger.channel.file', '@file_system', '@file.mime_type.guesser', '@token', '@lock', '@config.factory']
diff --git a/core/modules/jsonapi/schema.json b/core/modules/jsonapi/schema.json
new file mode 100644
index 0000000000..902a39d7a0
--- /dev/null
+++ b/core/modules/jsonapi/schema.json
@@ -0,0 +1,375 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "title": "JSON API Schema",
+  "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org",
+  "oneOf": [
+    {
+      "$ref": "#/definitions/success"
+    },
+    {
+      "$ref": "#/definitions/failure"
+    },
+    {
+      "$ref": "#/definitions/info"
+    }
+  ],
+
+  "definitions": {
+    "success": {
+      "type": "object",
+      "required": [
+        "data"
+      ],
+      "properties": {
+        "data": {
+          "$ref": "#/definitions/data"
+        },
+        "included": {
+          "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/resource"
+          },
+          "uniqueItems": true
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        },
+        "links": {
+          "description": "Link members related to the primary data.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/links"
+            },
+            {
+              "$ref": "#/definitions/pagination"
+            }
+          ]
+        },
+        "jsonapi": {
+          "$ref": "#/definitions/jsonapi"
+        }
+      },
+      "additionalProperties": false
+    },
+    "failure": {
+      "type": "object",
+      "required": [
+        "errors"
+      ],
+      "properties": {
+        "errors": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/error"
+          },
+          "uniqueItems": true
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        },
+        "jsonapi": {
+          "$ref": "#/definitions/jsonapi"
+        }
+      },
+      "additionalProperties": false
+    },
+    "info": {
+      "type": "object",
+      "required": [
+        "meta"
+      ],
+      "properties": {
+        "meta": {
+          "$ref": "#/definitions/meta"
+        },
+        "links": {
+          "$ref": "#/definitions/links"
+        },
+        "jsonapi": {
+          "$ref": "#/definitions/jsonapi"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "meta": {
+      "description": "Non-standard meta-information that can not be represented as an attribute or relationship.",
+      "type": "object",
+      "additionalProperties": true
+    },
+    "data": {
+      "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.",
+      "oneOf": [
+        {
+          "$ref": "#/definitions/resource"
+        },
+        {
+          "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.",
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/resource"
+          },
+          "uniqueItems": true
+        },
+        {
+          "description": "null if the request is one that might correspond to a single resource, but doesn't currently.",
+          "type": "null"
+        }
+      ]
+    },
+    "resource": {
+      "description": "\"Resource objects\" appear in a JSON API document to represent resources.",
+      "type": "object",
+      "required": [
+        "type",
+        "id"
+      ],
+      "properties": {
+        "type": {
+          "type": "string"
+        },
+        "id": {
+          "type": "string"
+        },
+        "attributes": {
+          "$ref": "#/definitions/attributes"
+        },
+        "relationships": {
+          "$ref": "#/definitions/relationships"
+        },
+        "links": {
+          "$ref": "#/definitions/links"
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "links": {
+      "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.",
+      "type": "object",
+      "properties": {
+        "self": {
+          "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.",
+          "type": "string",
+          "format": "uri"
+        },
+        "related": {
+          "$ref": "#/definitions/link"
+        }
+      },
+      "additionalProperties": true
+    },
+    "link": {
+      "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.",
+      "oneOf": [
+        {
+          "description": "A string containing the link's URL.",
+          "type": "string",
+          "format": "uri"
+        },
+        {
+          "type": "object",
+          "required": [
+            "href"
+          ],
+          "properties": {
+            "href": {
+              "description": "A string containing the link's URL.",
+              "type": "string",
+              "format": "uri"
+            },
+            "meta": {
+              "$ref": "#/definitions/meta"
+            }
+          }
+        }
+      ]
+    },
+
+    "attributes": {
+      "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.",
+      "type": "object",
+      "patternProperties": {
+        "^(?!relationships$|links$)\\w[-\\w_]*$": {
+          "description": "Attributes may contain any valid JSON value."
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "relationships": {
+      "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.",
+      "type": "object",
+      "patternProperties": {
+        "^\\w[-\\w_]*$": {
+          "properties": {
+            "links": {
+              "$ref": "#/definitions/links"
+            },
+            "data": {
+              "description": "Member, whose value represents \"resource linkage\".",
+              "oneOf": [
+                {
+                  "$ref": "#/definitions/relationshipToOne"
+                },
+                {
+                  "$ref": "#/definitions/relationshipToMany"
+                }
+              ]
+            },
+            "meta": {
+              "$ref": "#/definitions/meta"
+            }
+          },
+          "anyOf": [
+            {"required": ["data"]},
+            {"required": ["meta"]},
+            {"required": ["links"]}
+          ],
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+    "relationshipToOne": {
+      "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.",
+      "anyOf": [
+        {
+          "$ref": "#/definitions/empty"
+        },
+        {
+          "$ref": "#/definitions/linkage"
+        }
+      ]
+    },
+    "relationshipToMany": {
+      "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/linkage"
+      },
+      "uniqueItems": true
+    },
+    "empty": {
+      "description": "Describes an empty to-one relationship.",
+      "type": "null"
+    },
+    "linkage": {
+      "description": "The \"type\" and \"id\" to non-empty members.",
+      "type": "object",
+      "required": [
+        "type",
+        "id"
+      ],
+      "properties": {
+        "type": {
+          "type": "string"
+        },
+        "id": {
+          "type": "string"
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        }
+      },
+      "additionalProperties": false
+    },
+    "pagination": {
+      "type": "object",
+      "properties": {
+        "first": {
+          "description": "The first page of data",
+          "oneOf": [
+            { "type": "string", "format": "uri" },
+            { "type": "null" }
+          ]
+        },
+        "last": {
+          "description": "The last page of data",
+          "oneOf": [
+            { "type": "string", "format": "uri" },
+            { "type": "null" }
+          ]
+        },
+        "prev": {
+          "description": "The previous page of data",
+          "oneOf": [
+            { "type": "string", "format": "uri" },
+            { "type": "null" }
+          ]
+        },
+        "next": {
+          "description": "The next page of data",
+          "oneOf": [
+            { "type": "string", "format": "uri" },
+            { "type": "null" }
+          ]
+        }
+      }
+    },
+
+    "jsonapi": {
+      "description": "An object describing the server's implementation",
+      "type": "object",
+      "properties": {
+        "version": {
+          "type": "string"
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        }
+      },
+      "additionalProperties": false
+    },
+
+    "error": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "description": "A unique identifier for this particular occurrence of the problem.",
+          "type": "string"
+        },
+        "links": {
+          "$ref": "#/definitions/links"
+        },
+        "status": {
+          "description": "The HTTP status code applicable to this problem, expressed as a string value.",
+          "type": "string"
+        },
+        "code": {
+          "description": "An application-specific error code, expressed as a string value.",
+          "type": "string"
+        },
+        "title": {
+          "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.",
+          "type": "string"
+        },
+        "detail": {
+          "description": "A human-readable explanation specific to this occurrence of the problem.",
+          "type": "string"
+        },
+        "source": {
+          "type": "object",
+          "properties": {
+            "pointer": {
+              "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].",
+              "type": "string"
+            },
+            "parameter": {
+              "description": "A string indicating which query parameter caused the error.",
+              "type": "string"
+            }
+          }
+        },
+        "meta": {
+          "$ref": "#/definitions/meta"
+        }
+      },
+      "additionalProperties": false
+    }
+  }
+}
diff --git a/core/modules/jsonapi/src/Access/EntityAccessChecker.php b/core/modules/jsonapi/src/Access/EntityAccessChecker.php
new file mode 100644
index 0000000000..505af0e0bf
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/EntityAccessChecker.php
@@ -0,0 +1,277 @@
+<?php
+
+namespace Drupal\jsonapi\Access;
+
+use Drupal\content_moderation\Access\LatestRevisionCheck;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\JsonApiSpec;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\media\Access\MediaRevisionAccessCheck;
+use Drupal\media\MediaInterface;
+use Drupal\node\Access\NodeRevisionAccessCheck;
+use Drupal\node\NodeInterface;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Checks access to entities.
+ *
+ * JSON:API needs to check access to every single entity type. Some entity types
+ * have non-standard access checking logic. This class centralizes entity access
+ * checking logic.
+ *
+ * @internal
+ */
+class EntityAccessChecker {
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The router.
+   *
+   * @var \Symfony\Component\Routing\RouterInterface
+   */
+  protected $router;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * The node revision access check service.
+   *
+   * This will be NULL unless the node module is installed.
+   *
+   * @var \Drupal\node\Access\NodeRevisionAccessCheck|null
+   */
+  protected $nodeRevisionAccessCheck = NULL;
+
+  /**
+   * The media revision access check service.
+   *
+   * This will be NULL unless the media module is installed.
+   *
+   * @var \Drupal\media\Access\MediaRevisionAccessCheck|null
+   */
+  protected $mediaRevisionAccessCheck = NULL;
+
+  /**
+   * The latest revision check service.
+   *
+   * This will be NULL unless the content_moderation module is installed. This
+   * is a temporary measure. JSON:API should not need to be aware of the
+   * Content Moderation module.
+   *
+   * @var \Drupal\content_moderation\Access\LatestRevisionCheck
+   */
+  protected $latestRevisionCheck = NULL;
+
+  /**
+   * EntityAccessChecker constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param \Symfony\Component\Routing\RouterInterface $router
+   *   The router.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The current user.
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+   *   The entity repository.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RouterInterface $router, AccountInterface $account, EntityRepositoryInterface $entity_repository) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->router = $router;
+    $this->currentUser = $account;
+    $this->entityRepository = $entity_repository;
+  }
+
+  /**
+   * Sets the node revision access check service.
+   *
+   * This is only called when node module is installed.
+   *
+   * @param \Drupal\node\Access\NodeRevisionAccessCheck $node_revision_access_check
+   *   The node revision access check service.
+   */
+  public function setNodeRevisionAccessCheck(NodeRevisionAccessCheck $node_revision_access_check) {
+    $this->nodeRevisionAccessCheck = $node_revision_access_check;
+  }
+
+  /**
+   * Sets the media revision access check service.
+   *
+   * This is only called when media module is installed.
+   *
+   * @param \Drupal\media\Access\MediaRevisionAccessCheck $media_revision_access_check
+   *   The media revision access check service.
+   */
+  public function setMediaRevisionAccessCheck(MediaRevisionAccessCheck $media_revision_access_check) {
+    $this->mediaRevisionAccessCheck = $media_revision_access_check;
+  }
+
+  /**
+   * Sets the media revision access check service.
+   *
+   * This is only called when content_moderation module is installed.
+   *
+   * @param \Drupal\content_moderation\Access\LatestRevisionCheck $latest_revision_check
+   *   The latest revision access check service provided by the
+   *   content_moderation module.
+   *
+   * @see self::$latestRevisionCheck
+   */
+  public function setLatestRevisionCheck(LatestRevisionCheck $latest_revision_check) {
+    $this->latestRevisionCheck = $latest_revision_check;
+  }
+
+  /**
+   * Get the object to normalize and the access based on the provided entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to test access for.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   (optional) The account with which access should be checked. Defaults to
+   *   the current user.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   The ResourceObject, a LabelOnlyResourceObject or an
+   *   EntityAccessDeniedHttpException object if neither is accessible. All
+   *   three possible return values carry the access result cacheability.
+   */
+  public function getAccessCheckedResourceObject(EntityInterface $entity, AccountInterface $account = NULL) {
+    $account = $account ?: $this->currentUser;
+    $resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
+    $entity = $this->entityRepository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
+    $access = $this->checkEntityAccess($entity, 'view', $account);
+    $entity->addCacheableDependency($access);
+    if (!$access->isAllowed()) {
+      // If this is the default revision or the entity is not revisionable, then
+      // check access to the entity label. Revision support is all or nothing.
+      if (!$entity->getEntityType()->isRevisionable() || $entity->isDefaultRevision()) {
+        $label_access = $entity->access('view label', NULL, TRUE);
+        $entity->addCacheableDependency($label_access);
+        if ($label_access->isAllowed()) {
+          return new LabelOnlyResourceObject($resource_type, $entity);
+        }
+        $access = $access->orIf($label_access);
+      }
+      return new EntityAccessDeniedHttpException($entity, $access, '/data', 'The current user is not allowed to GET the selected resource.');
+    }
+    return new ResourceObject($resource_type, $entity);
+  }
+
+  /**
+   * Checks access to the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which access should be evaluated.
+   * @param string $operation
+   *   The entity operation for which access should be evaluated.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   (optional) The account with which access should be checked. Defaults to
+   *   the current user.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface|\Drupal\Core\Access\AccessResultReasonInterface
+   *   The access check result.
+   */
+  public function checkEntityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    $access = $entity->access($operation, $account, TRUE);
+    if ($entity->getEntityType()->isRevisionable()) {
+      $access = AccessResult::neutral()->addCacheContexts(['url.query_args:' . JsonApiSpec::VERSION_QUERY_PARAMETER])->orIf($access);
+      if (!$entity->isDefaultRevision()) {
+        assert($operation === 'view', 'JSON:API does not yet support mutable operations on revisions.');
+        $revision_access = $this->checkRevisionViewAccess($entity, $account);
+        $access = $access->andIf($revision_access);
+        // The revision access reason should trump the primary access reason.
+        if (!$access->isAllowed()) {
+          $reason = $access instanceof AccessResultReasonInterface ? $access->getReason() : '';
+          $access->setReason(trim('The user does not have access to the requested version. ' . $reason));
+        }
+      }
+    }
+    return $access;
+  }
+
+  /**
+   * Checks access to the given revision entity.
+   *
+   * This should only be called for non-default revisions.
+   *
+   * There is no standardized API for revision access checking in Drupal core
+   * and this method shims that missing API.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The revised entity for which to check access.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   (optional) The account with which access should be checked. Defaults to
+   *   the current user.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface|\Drupal\Core\Access\AccessResultReasonInterface
+   *   The access check result.
+   *
+   * @todo: remove when a generic revision access API exists in Drupal core, and
+   * also remove the injected "node" and "media" services.
+   * @see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818386
+   */
+  protected function checkRevisionViewAccess(EntityInterface $entity, AccountInterface $account) {
+    assert($entity instanceof RevisionableInterface);
+    assert(!$entity->isDefaultRevision(), 'It is not necessary to check revision access when the entity is the default revision.');
+    $entity_type = $entity->getEntityType();
+    switch ($entity_type->id()) {
+      case 'node':
+        assert($entity instanceof NodeInterface);
+        $access = AccessResult::allowedIf($this->nodeRevisionAccessCheck->checkAccess($entity, $account, 'view'))->cachePerPermissions()->addCacheableDependency($entity);
+        break;
+
+      case 'media':
+        assert($entity instanceof MediaInterface);
+        $access = AccessResult::allowedIf($this->mediaRevisionAccessCheck->checkAccess($entity, $account, 'view'))->cachePerPermissions()->addCacheableDependency($entity);
+        break;
+
+      default:
+        $reason = 'Only node and media revisions are supported by JSON:API.';
+        $reason .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
+        $reason .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
+        $access = AccessResult::neutral($reason);
+    }
+    // Apply content_moderation's additional access logic.
+    // @see \Drupal\content_moderation\Access\LatestRevisionCheck::access()
+    if ($entity_type->getLinkTemplate('latest-version') && $entity->isLatestRevision() && isset($this->latestRevisionCheck)) {
+      // The latest revision access checker only expects to be invoked by the
+      // routing system, which makes it necessary to fake a route match.
+      $routes = $this->router->getRouteCollection();
+      $resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
+      $route_name = sprintf('jsonapi.%s.individual', $resource_type->getTypeName());
+      $route = $routes->get($route_name);
+      $route->setOption('_content_moderation_entity_type', 'entity');
+      $route_match = new RouteMatch($route_name, $route, ['entity' => $entity], ['entity' => $entity->uuid()]);
+      $moderation_access_result = $this->latestRevisionCheck->access($route, $route_match, $account);
+      $access = $access->andIf($moderation_access_result);
+    }
+    return $access;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Access/RelationshipFieldAccess.php b/core/modules/jsonapi/src/Access/RelationshipFieldAccess.php
new file mode 100644
index 0000000000..1cd3a572b6
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/RelationshipFieldAccess.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\jsonapi\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Routing\Routes;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Defines a class to check access to related and relationship routes.
+ *
+ * @internal
+ */
+class RelationshipFieldAccess implements AccessInterface {
+
+  /**
+   * The route requirement key for this access check.
+   *
+   * @var string
+   */
+  const ROUTE_REQUIREMENT_KEY = '_jsonapi_relationship_field_access';
+
+  /**
+   * The JSON:API entity access checker.
+   *
+   * @var \Drupal\jsonapi\Access\EntityAccessChecker
+   */
+  protected $entityAccessChecker;
+
+  /**
+   * RelationshipFieldAccess constructor.
+   *
+   * @param \Drupal\jsonapi\Access\EntityAccessChecker $entity_access_checker
+   *   The JSON:API entity access checker.
+   */
+  public function __construct(EntityAccessChecker $entity_access_checker) {
+    $this->entityAccessChecker = $entity_access_checker;
+  }
+
+  /**
+   * Checks access to the relationship field on the given route.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The incoming HTTP request object.
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to check against.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The currently logged in account.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function access(Request $request, Route $route, AccountInterface $account) {
+    $relationship_field_name = $route->getRequirement(static::ROUTE_REQUIREMENT_KEY);
+    $field_operation = $request->isMethodCacheable() ? 'view' : 'edit';
+    $entity_operation = $request->isMethodCacheable() ? 'view' : 'update';
+    if ($resource_type = $request->get(Routes::RESOURCE_TYPE_KEY)) {
+      assert($resource_type instanceof ResourceType);
+      $entity = $request->get('entity');
+      $internal_name = $resource_type->getInternalName($relationship_field_name);
+      if ($entity instanceof FieldableEntityInterface && $entity->hasField($internal_name)) {
+        $entity_access = $this->entityAccessChecker->checkEntityAccess($entity, $entity_operation, $account);
+        $field_access = $entity->get($internal_name)->access($field_operation, $account, TRUE);
+        // Ensure that access is respected for different entity revisions.
+        $access_result = $entity_access->andIf($field_access);
+        if (!$access_result->isAllowed()) {
+          $reason = "The current user is not allowed to {$field_operation} this relationship.";
+          $access_reason = $access_result instanceof AccessResultReasonInterface ? $access_result->getReason() : NULL;
+          $detailed_reason = empty($access_reason) ? $reason : $reason . " {$access_reason}";
+          $access_result->setReason($detailed_reason);
+          if ($request->isMethodCacheable()) {
+            throw new CacheableAccessDeniedHttpException(CacheableMetadata::createFromObject($access_result), $detailed_reason);
+          }
+        }
+        return $access_result;
+      }
+    }
+    return AccessResult::neutral();
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php
new file mode 100644
index 0000000000..815035094f
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php
@@ -0,0 +1,603 @@
+<?php
+
+namespace Drupal\jsonapi\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\workspaces\WorkspaceInterface;
+
+/**
+ * Adds sufficient access control to collection queries.
+ *
+ * This class will be removed when new Drupal core APIs have been put in place
+ * to make it obsolete.
+ *
+ * @deprecated
+ * @internal
+ */
+class TemporaryQueryGuard {
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected static $fieldManager;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected static $moduleHandler;
+
+  /**
+   * Sets the entity field manager.
+   *
+   * This must be called before calling ::applyAccessControls().
+   *
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   */
+  public static function setFieldManager(EntityFieldManagerInterface $field_manager) {
+    static::$fieldManager = $field_manager;
+  }
+
+  /**
+   * Sets the module handler.
+   *
+   * This must be called before calling ::applyAccessControls().
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   */
+  public static function setModuleHandler(ModuleHandlerInterface $module_handler) {
+    static::$moduleHandler = $module_handler;
+  }
+
+  /**
+   * Applies access controls to an entity query.
+   *
+   * @param \Drupal\jsonapi\Query\Filter $filter
+   *   The filters applicable to the query.
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to which access controls should be applied.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   */
+  public static function applyAccessControls(Filter $filter, QueryInterface $query, CacheableMetadata $cacheability) {
+    assert(static::$fieldManager !== NULL);
+    assert(static::$moduleHandler !== NULL);
+    $filtered_fields = static::collectFilteredFields($filter->root());
+    $field_specifiers = array_map(function ($field) {
+      return explode('.', $field);
+    }, $filtered_fields);
+    static::secureQuery($query, $query->getEntityTypeId(), static::buildTree($field_specifiers), $cacheability);
+  }
+
+  /**
+   * Applies tags, metadata and conditions to secure an entity query.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to be secured.
+   * @param string $entity_type_id
+   *   An entity type ID.
+   * @param array $tree
+   *   A tree of field specifiers in an entity query condition. The tree is a
+   *   multi-dimensional array where the keys are field specifiers and the
+   *   values are multi-dimensional array of the same form, containing only
+   *   subsequent specifiers. @see ::buildTree().
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   * @param string|null $field_prefix
+   *   Internal use only. Contains a string representation of the previously
+   *   visited field specifiers.
+   * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_definition
+   *   Internal use only. The current field storage definition, if known.
+   *
+   * @see \Drupal\Core\Database\Query\AlterableInterface::addTag()
+   * @see \Drupal\Core\Database\Query\AlterableInterface::addMetaData()
+   * @see \Drupal\Core\Database\Query\ConditionInterface
+   */
+  protected static function secureQuery(QueryInterface $query, $entity_type_id, array $tree, CacheableMetadata $cacheability, $field_prefix = NULL, FieldStorageDefinitionInterface $field_storage_definition = NULL) {
+    $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
+    // Config entity types are not fieldable, therefore they do not have field
+    // access restrictions, nor entity references to other entity types.
+    if ($entity_type instanceof ConfigEntityTypeInterface) {
+      return;
+    }
+    foreach ($tree as $specifier => $children) {
+      // The field path reconstructs the entity condition fields.
+      // E.g. `uid.0` would become `uid.0.name` if $specifier === 'name'.
+      $child_prefix = (is_null($field_prefix)) ? $specifier : "$field_prefix.$specifier";
+      if (is_null($field_storage_definition)) {
+        // When the field storage definition is NULL, this specifier is the
+        // first specifier in an entity query field path or the previous
+        // specifier was a data reference that has been traversed. In both
+        // cases, the specifier must be a field name.
+        $field_storage_definitions = static::$fieldManager->getFieldStorageDefinitions($entity_type_id);
+        static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definitions[$specifier]);
+        // When $field_prefix is NULL, this must be the first specifier in the
+        // entity query field path and a condition for the query's base entity
+        // type must be applied.
+        if (is_null($field_prefix)) {
+          static::applyAccessConditions($query, $entity_type_id, NULL, $cacheability);
+        }
+      }
+      else {
+        // When the specifier is an entity reference, it can contain an entity
+        // type specifier, like so: `entity:node`. This extracts the `entity`
+        // portion. JSON:API will have already validated that the property
+        // exists.
+        $split_specifier = explode(':', $specifier, 2);
+        list($property_name, $target_entity_type_id) = array_merge($split_specifier, count($split_specifier) === 2 ? [] : [NULL]);
+        // The specifier is either a field property or a delta. If it is a data
+        // reference or a delta, then it needs to be traversed to the next
+        // specifier. However, if the specific is a simple field property, i.e.
+        // it is neither a data reference nor a delta, then there is no need to
+        // evaluate the remaining specifiers.
+        $property_definition = $field_storage_definition->getPropertyDefinition($property_name);
+        if ($property_definition instanceof DataReferenceDefinitionInterface) {
+          // Because the filter is following an entity reference, ensure
+          // access is respected on those targeted entities.
+          // Examples:
+          // - node_query_node_access_alter()
+          $target_entity_type_id = $target_entity_type_id ?: $field_storage_definition->getSetting('target_type');
+          $query->addTag("{$target_entity_type_id}_access");
+          static::applyAccessConditions($query, $target_entity_type_id, $child_prefix, $cacheability);
+          // Keep descending the tree.
+          static::secureQuery($query, $target_entity_type_id, $children, $cacheability, $child_prefix);
+        }
+        elseif (is_null($property_definition)) {
+          assert(is_numeric($property_name), 'The specifier is not a property name, it must be a delta.');
+          // Keep descending the tree.
+          static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definition);
+        }
+      }
+    }
+  }
+
+  /**
+   * Applies access conditions to ensure 'view' access is respected.
+   *
+   * Since the given entity type might not be the base entity type of the query,
+   * the field prefix should be applied to ensure that the conditions are
+   * applied to the right subset of entities in the query.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to which access conditions should be applied.
+   * @param string $entity_type_id
+   *   The entity type for which to access conditions should be applied.
+   * @param string|null $field_prefix
+   *   A prefix to add before any query condition fields. NULL if no prefix
+   *   should be added.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   */
+  protected static function applyAccessConditions(QueryInterface $query, $entity_type_id, $field_prefix, CacheableMetadata $cacheability) {
+    $access_condition = static::getAccessCondition($entity_type_id, $cacheability);
+    if ($access_condition) {
+      $prefixed_condition = !is_null($field_prefix)
+        ? static::addConditionFieldPrefix($access_condition, $field_prefix)
+        : $access_condition;
+      $filter = new Filter($prefixed_condition);
+      $query->condition($filter->queryCondition($query));
+    }
+  }
+
+  /**
+   * Prefixes all fields in an EntityConditionGroup.
+   */
+  protected static function addConditionFieldPrefix(EntityConditionGroup $group, $field_prefix) {
+    $prefixed = [];
+    foreach ($group->members() as $member) {
+      if ($member instanceof EntityConditionGroup) {
+        $prefixed[] = static::addConditionFieldPrefix($member, $field_prefix);
+      }
+      else {
+        $field = !empty($field_prefix) ? "{$field_prefix}." . $member->field() : $member->field();
+        $prefixed[] = new EntityCondition($field, $member->value(), $member->operator());
+      }
+    }
+    return new EntityConditionGroup($group->conjunction(), $prefixed);
+  }
+
+  /**
+   * Gets an EntityConditionGroup that filters out inaccessible entities.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID for which to get an EntityConditionGroup.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup|null
+   *   An EntityConditionGroup or NULL if no conditions need to be applied to
+   *   secure an entity query.
+   */
+  protected static function getAccessCondition($entity_type_id, CacheableMetadata $cacheability) {
+    $current_user = \Drupal::currentUser();
+    $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
+
+    // Get the condition that handles generic restrictions, such as published
+    // and owner.
+    $generic_condition = static::getAccessConditionForKnownSubsets($entity_type, $current_user, $cacheability);
+
+    // Some entity types require additional conditions. We don't know what
+    // contrib entity types require, so they are responsible for implementing
+    // hook_query_ENTITY_TYPE_access_alter(). Some core entity types have
+    // logic in their access control handler that isn't mirrored in
+    // hook_query_ENTITY_TYPE_access_alter(), so we duplicate that here until
+    // that's resolved.
+    $specific_condition = NULL;
+    switch ($entity_type_id) {
+      case 'block_content':
+        // Allow access only to reusable blocks.
+        // @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess()
+        if (isset(static::$fieldManager->getBaseFieldDefinitions($entity_type_id)['reusable'])) {
+          $specific_condition = new EntityCondition('reusable', 1);
+          $cacheability->addCacheTags($entity_type->getListCacheTags());
+        }
+        break;
+
+      case 'comment':
+        // @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
+        $specific_condition = static::getCommentAccessCondition($entity_type, $current_user, $cacheability);
+        break;
+
+      case 'entity_test':
+        // This case is only necessary for testing comment access controls.
+        // @see \Drupal\jsonapi\Tests\Functional\CommentTest::testCollectionFilterAccess()
+        $blacklist = \Drupal::state()->get('jsonapi__entity_test_filter_access_blacklist', []);
+        $cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
+        $specific_conditions = [];
+        foreach ($blacklist as $id) {
+          $specific_conditions[] = new EntityCondition('id', $id, '<>');
+        }
+        if ($specific_conditions) {
+          $specific_condition = new EntityConditionGroup('AND', $specific_conditions);
+        }
+        break;
+
+      case 'file':
+        // Allow access only to public files and files uploaded by the current
+        // user.
+        // @see \Drupal\file\FileAccessControlHandler::checkAccess()
+        $specific_condition = new EntityConditionGroup('OR', [
+          new EntityCondition('uri', 'public://', 'STARTS_WITH'),
+          new EntityCondition('uid', $current_user->id()),
+        ]);
+        $cacheability->addCacheTags($entity_type->getListCacheTags());
+        break;
+
+      case 'shortcut':
+        // Unless the user can administer shortcuts, allow access only to the
+        // user's currently displayed shortcut set.
+        // @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess()
+        if (!$current_user->hasPermission('administer shortcuts')) {
+          $specific_condition = new EntityCondition('shortcut_set', shortcut_current_displayed_set()->id());
+          $cacheability->addCacheContexts(['user']);
+          $cacheability->addCacheTags($entity_type->getListCacheTags());
+        }
+        break;
+
+      case 'user':
+        // Disallow querying values of the anonymous user.
+        // @see \Drupal\user\UserAccessControlHandler::checkAccess()
+        $specific_condition = new EntityCondition('uid', '0', '!=');
+        break;
+
+      case 'workspace':
+        // The default workspace is always viewable, no matter what, so if
+        // the generic condition prevents that, add an OR.
+        // @see \Drupal\workspaces\WorkspaceAccessControlHandler::checkAccess()
+        if ($generic_condition) {
+          $specific_condition = new EntityConditionGroup('OR', [
+            $generic_condition,
+            new EntityCondition('id', WorkspaceInterface::DEFAULT_WORKSPACE),
+          ]);
+          // The generic condition is now part of the specific condition.
+          $generic_condition = NULL;
+        }
+        break;
+    }
+
+    // Return a combined condition.
+    if ($generic_condition && $specific_condition) {
+      return new EntityConditionGroup('AND', [$generic_condition, $specific_condition]);
+    }
+    elseif ($generic_condition) {
+      return $generic_condition instanceof EntityConditionGroup ? $generic_condition : new EntityConditionGroup('AND', [$generic_condition]);
+    }
+    elseif ($specific_condition) {
+      return $specific_condition instanceof EntityConditionGroup ? $specific_condition : new EntityConditionGroup('AND', [$specific_condition]);
+    }
+
+    return NULL;
+  }
+
+  /**
+   * Gets an access condition for the allowed JSONAPI_FILTER_AMONG_* subsets.
+   *
+   * If access is allowed for the JSONAPI_FILTER_AMONG_ALL subset, then no
+   * conditions are returned. Otherwise, if access is allowed for
+   * JSONAPI_FILTER_AMONG_PUBLISHED, JSONAPI_FILTER_AMONG_ENABLED, or
+   * JSONAPI_FILTER_AMONG_OWN, then a condition group is returned for the union
+   * of allowed subsets. If no subsets are allowed, then static::alwaysFalse()
+   * is returned.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to check filter access.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which to check access.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup|null
+   *   An EntityConditionGroup or NULL if no conditions need to be applied to
+   *   secure an entity query.
+   */
+  protected static function getAccessConditionForKnownSubsets(EntityTypeInterface $entity_type, AccountInterface $account, CacheableMetadata $cacheability) {
+    // Get the combined access results for each JSONAPI_FILTER_AMONG_* subset.
+    $access_results = static::getAccessResultsFromEntityFilterHook($entity_type, $account);
+
+    // No conditions are needed if access is allowed for all entities.
+    $cacheability->addCacheableDependency($access_results[JSONAPI_FILTER_AMONG_ALL]);
+    if ($access_results[JSONAPI_FILTER_AMONG_ALL]->isAllowed()) {
+      return NULL;
+    }
+
+    // If filtering is not allowed across all entities, but is allowed for
+    // certain subsets, then add conditions that reflect those subsets. These
+    // will be grouped in an OR to reflect that access may be granted to
+    // more than one subset. If no conditions are added below, then
+    // static::alwaysFalse() is returned.
+    $conditions = [];
+
+    // The "published" subset.
+    $published_field_name = $entity_type->getKey('published');
+    if ($published_field_name) {
+      $access_result = $access_results[JSONAPI_FILTER_AMONG_PUBLISHED];
+      $cacheability->addCacheableDependency($access_result);
+      if ($access_result->isAllowed()) {
+        $conditions[] = new EntityCondition($published_field_name, 1);
+        $cacheability->addCacheTags($entity_type->getListCacheTags());
+      }
+    }
+
+    // The "enabled" subset.
+    // @todo Remove ternary when the 'status' key is added to the User entity type.
+    $status_field_name = $entity_type->id() === 'user' ? 'status' : $entity_type->getKey('status');
+    if ($status_field_name) {
+      $access_result = $access_results[JSONAPI_FILTER_AMONG_ENABLED];
+      $cacheability->addCacheableDependency($access_result);
+      if ($access_result->isAllowed()) {
+        $conditions[] = new EntityCondition($status_field_name, 1);
+        $cacheability->addCacheTags($entity_type->getListCacheTags());
+      }
+    }
+
+    // The "owner" subset.
+    // @todo Remove ternary when the 'uid' key is added to the User entity type.
+    $owner_field_name = $entity_type->id() === 'user' ? 'uid' : $entity_type->getKey('owner');
+    if ($owner_field_name) {
+      $access_result = $access_results[JSONAPI_FILTER_AMONG_OWN];
+      $cacheability->addCacheableDependency($access_result);
+      if ($access_result->isAllowed()) {
+        $cacheability->addCacheContexts(['user']);
+        if ($account->isAuthenticated()) {
+          $conditions[] = new EntityCondition($owner_field_name, $account->id());
+          $cacheability->addCacheTags($entity_type->getListCacheTags());
+        }
+      }
+    }
+
+    // If no conditions were added above, then access wasn't granted to any
+    // subset, so return alwaysFalse().
+    if (empty($conditions)) {
+      return static::alwaysFalse($entity_type);
+    }
+
+    // If more than one condition was added above, then access was granted to
+    // more than one subset, so combine them with an OR.
+    if (count($conditions) > 1) {
+      return new EntityConditionGroup('OR', $conditions);
+    }
+
+    // Otherwise return the single condition.
+    return $conditions[0];
+  }
+
+  /**
+   * Gets the combined access result for each JSONAPI_FILTER_AMONG_* subset.
+   *
+   * This invokes hook_jsonapi_entity_filter_access() and
+   * hook_jsonapi_ENTITY_TYPE_filter_access() and combines the results from all
+   * of the modules into a single set of results.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to check filter access.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which to check access.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface[]
+   *   The array of access results, keyed by subset. See
+   *   hook_jsonapi_entity_filter_access() for details.
+   */
+  protected static function getAccessResultsFromEntityFilterHook(EntityTypeInterface $entity_type, AccountInterface $account) {
+    /* @var \Drupal\Core\Access\AccessResultInterface[] $combined_access_results */
+    $combined_access_results = [
+      JSONAPI_FILTER_AMONG_ALL => AccessResult::neutral(),
+      JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::neutral(),
+      JSONAPI_FILTER_AMONG_ENABLED => AccessResult::neutral(),
+      JSONAPI_FILTER_AMONG_OWN => AccessResult::neutral(),
+    ];
+
+    // Invoke hook_jsonapi_entity_filter_access() and
+    // hook_jsonapi_ENTITY_TYPE_filter_access() for each module and merge its
+    // results with the combined results.
+    foreach (['jsonapi_entity_filter_access', 'jsonapi_' . $entity_type->id() . '_filter_access'] as $hook) {
+      foreach (static::$moduleHandler->getImplementations($hook) as $module) {
+        $module_access_results = static::$moduleHandler->invoke($module, $hook, [$entity_type, $account]);
+        if ($module_access_results) {
+          foreach ($module_access_results as $subset => $access_result) {
+            $combined_access_results[$subset] = $combined_access_results[$subset]->orIf($access_result);
+          }
+        }
+      }
+    }
+
+    return $combined_access_results;
+  }
+
+  /**
+   * Gets an access condition for a comment entity.
+   *
+   * Unlike all other core entity types, Comment entities' access control
+   * depends on access to a referenced entity. More challenging yet, that entity
+   * reference field may target different entity types depending on the comment
+   * bundle. This makes the query access conditions sufficiently complex to
+   * merit a dedicated method.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $comment_entity_type
+   *   The comment entity type object.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Collects cacheability for the query.
+   * @param int $depth
+   *   Internal use only. The recursion depth. It is possible to have comments
+   *   on comments, but since comment access is dependent on access to the
+   *   entity on which they live, this method can recurse endlessly.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup|null
+   *   An EntityConditionGroup or NULL if no conditions need to be applied to
+   *   secure an entity query.
+   */
+  protected static function getCommentAccessCondition(EntityTypeInterface $comment_entity_type, AccountInterface $current_user, CacheableMetadata $cacheability, $depth = 1) {
+    // If a comment is assigned to another entity or author the cache needs to
+    // be invalidated.
+    $cacheability->addCacheTags($comment_entity_type->getListCacheTags());
+    // Constructs a big EntityConditionGroup which will filter comments based on
+    // the current user's access to the entities on which each comment lives.
+    // This is especially complex because comments of different bundles can
+    // live on entities of different entity types.
+    $comment_entity_type_id = $comment_entity_type->id();
+    $field_map = static::$fieldManager->getFieldMapByFieldType('entity_reference');
+    assert(isset($field_map[$comment_entity_type_id]['entity_id']['bundles']), 'Every comment has an `entity_id` field.');
+    $bundle_ids_by_target_entity_type_id = [];
+    foreach ($field_map[$comment_entity_type_id]['entity_id']['bundles'] as $bundle_id) {
+      $field_definitions = static::$fieldManager->getFieldDefinitions($comment_entity_type_id, $bundle_id);
+      $commented_entity_field_definition = $field_definitions['entity_id'];
+      // Each commented entity field definition has a setting which indicates
+      // the entity type of the commented entity reference field. This differs
+      // per bundle.
+      $target_entity_type_id = $commented_entity_field_definition->getSetting('target_type');
+      $bundle_ids_by_target_entity_type_id[$target_entity_type_id][] = $bundle_id;
+    }
+    $bundle_specific_access_conditions = [];
+    foreach ($bundle_ids_by_target_entity_type_id as $target_entity_type_id => $bundle_ids) {
+      // Construct a field specifier prefix which targets the commented entity.
+      $condition_field_prefix = "entity_id.entity:$target_entity_type_id";
+      // Ensure that for each possible commented entity type (which varies per
+      // bundle), a condition is created that restricts access based on access
+      // to the commented entity.
+      $bundle_condition = new EntityCondition($comment_entity_type->getKey('bundle'), $bundle_ids, 'IN');
+      // Comments on comments can create an infinite recursion! If the target
+      // entity type ID is comment, we need special behavior.
+      if ($target_entity_type_id === $comment_entity_type_id) {
+        $nested_comment_condition = $depth <= 3
+          ? static::getCommentAccessCondition($comment_entity_type, $current_user, $cacheability, $depth + 1)
+          : static::alwaysFalse($comment_entity_type);
+        $prefixed_comment_condition = static::addConditionFieldPrefix($nested_comment_condition, $condition_field_prefix);
+        $bundle_specific_access_conditions[$target_entity_type_id] = new EntityConditionGroup('AND', [$bundle_condition, $prefixed_comment_condition]);
+      }
+      else {
+        $target_condition = static::getAccessCondition($target_entity_type_id, $cacheability);
+        $bundle_specific_access_conditions[$target_entity_type_id] = !is_null($target_condition)
+          ? new EntityConditionGroup('AND', [
+            $bundle_condition,
+            static::addConditionFieldPrefix($target_condition, $condition_field_prefix),
+          ])
+          : $bundle_condition;
+      }
+    }
+
+    // This condition ensures that the user is only permitted to see the
+    // comments for which the user is also able to view the entity on which each
+    // comment lives.
+    $commented_entity_condition = new EntityConditionGroup('OR', array_values($bundle_specific_access_conditions));
+    return $commented_entity_condition;
+  }
+
+  /**
+   * Gets an always FALSE entity condition group for the given entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to construct an impossible condition.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   An EntityConditionGroup which cannot evaluate to TRUE.
+   */
+  protected static function alwaysFalse(EntityTypeInterface $entity_type) {
+    return new EntityConditionGroup('AND', [
+      new EntityCondition($entity_type->getKey('id'), 1, '<'),
+      new EntityCondition($entity_type->getKey('id'), 1, '>'),
+    ]);
+  }
+
+  /**
+   * Recursively collects all entity query condition fields.
+   *
+   * Entity conditions can be nested within AND and OR groups. This recursively
+   * finds all unique fields in an entity query condition.
+   *
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $group
+   *   The root entity condition group.
+   * @param array $fields
+   *   Internal use only.
+   *
+   * @return array
+   *   An array of entity query condition field names.
+   */
+  protected static function collectFilteredFields(EntityConditionGroup $group, array $fields = []) {
+    foreach ($group->members() as $member) {
+      if ($member instanceof EntityConditionGroup) {
+        $fields = static::collectFilteredFields($member, $fields);
+      }
+      else {
+        $fields[] = $member->field();
+      }
+    }
+    return array_unique($fields);
+  }
+
+  /**
+   * Copied from \Drupal\jsonapi\IncludeResolver.
+   *
+   * @see \Drupal\jsonapi\IncludeResolver::buildTree()
+   */
+  protected static function buildTree(array $paths) {
+    $merged = [];
+    foreach ($paths as $parts) {
+      // This complex expression is needed to handle the string, "0", which
+      // would be evaluated as FALSE.
+      if (!is_null(($field_name = array_shift($parts)))) {
+        $previous = isset($merged[$field_name]) ? $merged[$field_name] : [];
+        $merged[$field_name] = array_merge($previous, [$parts]);
+      }
+    }
+    return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php
new file mode 100644
index 0000000000..8c298cb217
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/FieldResolver.php
@@ -0,0 +1,775 @@
+<?php
+
+namespace Drupal\jsonapi\Context;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
+use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
+use Drupal\Core\TypedData\ComplexDataDefinitionInterface;
+use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
+use Drupal\Core\TypedData\DataReferenceTargetDefinition;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * A service that evaluates external path expressions against Drupal fields.
+ *
+ * This class performs 3 essential functions, path resolution, path validation
+ * and path expansion.
+ *
+ * Path resolution:
+ * Path resolution refers to the ability to map a set of external field names to
+ * their internal counterparts. This is necessary because a resource type can
+ * provide aliases for its field names. For example, the resource type @code
+ * node--article @endcode might "alias" the internal field name @code
+ * uid @endcode to the external field name @code author @endcode. This permits
+ * an API consumer to request @code
+ * /jsonapi/node/article?include=author @endcode for a better developer
+ * experience.
+ *
+ * Path validation:
+ * Path validation refers to the ability to ensure that a requested path
+ * corresponds to a valid set of internal fields. For example, if an API
+ * consumer may send a @code GET @endcode request to @code
+ * /jsonapi/node/article?sort=author.field_first_name @endcode. The field
+ * resolver ensures that @code uid @endcode (which would have been resolved
+ * from @code author @endcode) exists on article nodes and that @code
+ * field_first_name @endcode exists on user entities. However, in the case of
+ * an @code include @endcode path, the field resolver would raise a client error
+ * because @code field_first_name @endcode is not an entity reference field,
+ * meaning it does not identify any related resources that can be included in a
+ * compound document.
+ *
+ * Path expansion:
+ * Path expansion refers to the ability to expand a path to an entity query
+ * compatible field expression. For example, a request URL might have a query
+ * string like @code ?filter[field_tags.name]=aviation @endcode, before
+ * constructing the appropriate entity query, the entity query system needs the
+ * path expression to be "expanded" into @code field_tags.entity.name @endcode.
+ * In some rare cases, the entity query system needs this to be expanded to
+ * @code field_tags.entity:taxonomy_term.name @endcode; the field resolver
+ * simply does this by default for every path.
+ *
+ * *Note:* path expansion is *not* performed for @code include @endcode paths.
+ *
+ * @internal
+ */
+class FieldResolver {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The entity type bundle information service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
+   */
+  protected $entityTypeBundleInfo;
+
+  /**
+   * The JSON:API resource type repository service.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * Creates a FieldResolver instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The field manager.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+   *   The bundle info service.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The resource type repository.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository, ModuleHandlerInterface $module_handler) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->entityTypeBundleInfo = $entity_type_bundle_info;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->moduleHandler = $module_handler;
+  }
+
+  /**
+   * Validates and resolves an include path into its internal possibilities.
+   *
+   * Each resource type may define its own external names for its internal
+   * field names. As a result, a single external include path may target
+   * multiple internal paths.
+   *
+   * This can happen when an entity reference field has different allowed entity
+   * types *per bundle* (as is possible with comment entities) or when
+   * different resource types share an external field name but resolve to
+   * different internal fields names.
+   *
+   * Example 1:
+   * An installation may have three comment types for three different entity
+   * types, two of which have a file field and one of which does not. In that
+   * case, a path like @code field_comments.entity_id.media @endcode might be
+   * resolved to both @code field_comments.entity_id.field_audio @endcode
+   * and @code field_comments.entity_id.field_image @endcode.
+   *
+   * Example 2:
+   * A path of @code field_author_profile.account @endcode might
+   * resolve to @code field_author_profile.uid @endcode and @code
+   * field_author_profile.field_user @endcode if @code
+   * field_author_profile @endcode can relate to two different JSON:API resource
+   * types (like `node--profile` and `node--migrated_profile`) which have the
+   * external field name @code account @endcode aliased to different internal
+   * field names.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the path should be validated.
+   * @param string[] $path_parts
+   *   The include path as an array of strings. For example, the include query
+   *   parameter string of @code field_tags.uid @endcode should be given
+   *   as @code ['field_tags', 'uid'] @endcode.
+   * @param int $depth
+   *   (internal) Used to track recursion depth in order to generate better
+   *   exception messages.
+   *
+   * @return string[]
+   *   The resolved internal include paths.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown if the path contains invalid specifiers.
+   */
+  public static function resolveInternalIncludePath(ResourceType $resource_type, array $path_parts, $depth = 0) {
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:include']);
+    if (empty($path_parts[0])) {
+      throw new CacheableBadRequestHttpException($cacheability, 'Empty include path.');
+    }
+    $public_field_name = $path_parts[0];
+    $internal_field_name = $resource_type->getInternalName($public_field_name);
+    $relatable_resource_types = $resource_type->getRelatableResourceTypesByField($public_field_name);
+    if (empty($relatable_resource_types)) {
+      $message = "`$public_field_name` is not a valid relationship field name.";
+      if (!empty(($possible = implode(', ', array_keys($resource_type->getRelatableResourceTypes()))))) {
+        $message .= " Possible values: $possible.";
+      }
+      throw new CacheableBadRequestHttpException($cacheability, $message);
+    }
+    $remaining_parts = array_slice($path_parts, 1);
+    if (empty($remaining_parts)) {
+      return [[$internal_field_name]];
+    }
+    $exceptions = [];
+    $resolved = [];
+    foreach ($relatable_resource_types as $relatable_resource_type) {
+      try {
+        // Each resource type may resolve the path differently and may return
+        // multiple possible resolutions.
+        $resolved += static::resolveInternalIncludePath($relatable_resource_type, $remaining_parts, $depth + 1);
+      }
+      catch (CacheableBadRequestHttpException $e) {
+        $exceptions[] = $e;
+      }
+    }
+    if (!empty($exceptions) && count($exceptions) === count($relatable_resource_types)) {
+      $previous_messages = implode(' ', array_unique(array_map(function (CacheableBadRequestHttpException $e) {
+        return $e->getMessage();
+      }, $exceptions)));
+      // Only add the full include path on the first level of recursion so that
+      // the invalid path phrase isn't repeated at every level.
+      throw new CacheableBadRequestHttpException($cacheability, $depth === 0
+        ? sprintf("`%s` is not a valid include path. $previous_messages", implode('.', $path_parts))
+        : $previous_messages
+      );
+    }
+    // The resolved internal paths do not include the current field name because
+    // resolution happens in a recursive process.
+    return array_map(function ($possibility) use ($internal_field_name) {
+      return array_merge([$internal_field_name], $possibility);
+    }, $resolved);
+  }
+
+  /**
+   * Resolves external field expressions into entity query compatible paths.
+   *
+   * It is often required to reference data which may exist across a
+   * relationship. For example, you may want to sort a list of articles by
+   * a field on the article author's representative entity. Or you may wish
+   * to filter a list of content by the name of referenced taxonomy terms.
+   *
+   * In an effort to simplify the referenced paths and align them with the
+   * structure of JSON:API responses and the structure of the hypothetical
+   * "reference document" (see link), it is possible to alias field names and
+   * elide the "entity" keyword from them (this word is used by the entity query
+   * system to traverse entity references).
+   *
+   * This method takes this external field expression and and attempts to
+   * resolve any aliases and/or abbreviations into a field expression that will
+   * be compatible with the entity query system.
+   *
+   * @link http://jsonapi.org/recommendations/#urls-reference-document
+   *
+   * Example:
+   *   'uid.field_first_name' -> 'uid.entity.field_first_name'.
+   *   'author.firstName' -> 'field_author.entity.field_first_name'
+   *
+   * @param string $entity_type_id
+   *   The type of the entity for which to resolve the field name.
+   * @param string $bundle
+   *   The bundle of the entity for which to resolve the field name.
+   * @param string $external_field_name
+   *   The public field name to map to a Drupal field name.
+   *
+   * @return string
+   *   The mapped field name.
+   *
+   * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
+   */
+  public function resolveInternalEntityQueryPath($entity_type_id, $bundle, $external_field_name) {
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    if (empty($external_field_name)) {
+      throw new CacheableBadRequestHttpException($cacheability, 'No field name was provided for the filter.');
+    }
+
+    // Turns 'uid.categories.name' into
+    // 'uid.entity.field_category.entity.name'. This may be too simple, but it
+    // works for the time being.
+    $parts = explode('.', $external_field_name);
+    $unresolved_path_parts = $parts;
+    $reference_breadcrumbs = [];
+    /* @var \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types */
+    $resource_types = [$resource_type];
+    // This complex expression is needed to handle the string, "0", which would
+    // otherwise be evaluated as FALSE.
+    while (!is_null(($part = array_shift($parts)))) {
+      if (!$this->isMemberFilterable($part, $resource_types)) {
+        throw new CacheableBadRequestHttpException($cacheability, sprintf(
+          'Invalid nested filtering. The field `%s`, given in the path `%s`, does not exist.',
+          $part,
+          $external_field_name
+        ));
+      }
+
+      $field_name = $this->getInternalName($part, $resource_types);
+
+      // If none of the resource types are traversable, assume that the
+      // remaining path parts are targeting field deltas and/or field
+      // properties.
+      if (!$this->resourceTypesAreTraversable($resource_types)) {
+        $reference_breadcrumbs[] = $field_name;
+        return $this->constructInternalPath($reference_breadcrumbs, $parts);
+      }
+
+      // Different resource types have different field definitions.
+      $candidate_definitions = $this->getFieldItemDefinitions($resource_types, $field_name);
+      assert(!empty($candidate_definitions));
+
+      // We have a valid field, so add it to the validated trail of path parts.
+      $reference_breadcrumbs[] = $field_name;
+
+      // Remove resource types which do not have a candidate definition.
+      $resource_types = array_filter($resource_types, function (ResourceType $resource_type) use ($candidate_definitions) {
+        return isset($candidate_definitions[$resource_type->getTypeName()]);
+      });
+
+      // Check access to execute a query for each field per resource type since
+      // field definitions are bundle-specific.
+      foreach ($resource_types as $resource_type) {
+        $field_access = $this->getFieldAccess($resource_type, $field_name);
+        $cacheability->addCacheableDependency($field_access);
+        if (!$field_access->isAllowed()) {
+          $message = sprintf('The current user is not authorized to filter by the `%s` field, given in the path `%s`.', $field_name, implode('.', $reference_breadcrumbs));
+          if ($field_access instanceof AccessResultReasonInterface && ($reason = $field_access->getReason()) && !empty($reason)) {
+            $message .= ' ' . $reason;
+          }
+          throw new CacheableAccessDeniedHttpException($cacheability, $message);
+        }
+      }
+
+      // Get all of the referenceable resource types.
+      $resource_types = $this->getReferenceableResourceTypes($candidate_definitions);
+
+      $at_least_one_entity_reference_field = FALSE;
+      $candidate_property_names = array_unique(NestedArray::mergeDeepArray(array_map(function (FieldItemDataDefinitionInterface $definition) use (&$at_least_one_entity_reference_field) {
+        $property_definitions = $definition->getPropertyDefinitions();
+        return array_reduce(array_keys($property_definitions), function ($property_names, $property_name) use ($property_definitions, &$at_least_one_entity_reference_field) {
+          $property_definition = $property_definitions[$property_name];
+          $is_data_reference_definition = $property_definition instanceof DataReferenceTargetDefinition;
+          if (!$property_definition->isInternal()) {
+            // Entity reference fields are special: their reference property
+            // (usually `target_id`) is never exposed in the JSON:API
+            // representation. Hence it must also not be exposed in 400
+            // responses' error messages.
+            $property_names[] = $is_data_reference_definition ? 'id' : $property_name;
+          }
+          if ($is_data_reference_definition) {
+            $at_least_one_entity_reference_field = TRUE;
+          }
+          return $property_names;
+        }, []);
+      }, $candidate_definitions)));
+
+      // Determine if the specified field has one property or many in its
+      // JSON:API representation, or if it is an relationship (an entity
+      // reference field), in which case the `id` of the related resource must
+      // always be specified.
+      $property_specifier_needed = $at_least_one_entity_reference_field || count($candidate_property_names) > 1;
+
+      // If there are no remaining path parts, the process is finished unless
+      // the field has multiple properties, in which case one must be specified.
+      if (empty($parts)) {
+        if ($property_specifier_needed) {
+          $possible_specifiers = array_map(function ($specifier) use ($at_least_one_entity_reference_field) {
+            return $at_least_one_entity_reference_field && $specifier !== 'id' ? "meta.$specifier" : $specifier;
+          }, $candidate_property_names);
+          throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The field `%s`, given in the path `%s` is incomplete, it must end with one of the following specifiers: `%s`.', $part, $external_field_name, implode('`, `', $possible_specifiers)));
+        }
+        return $this->constructInternalPath($reference_breadcrumbs);
+      }
+
+      // If the next part is a delta, as in "body.0.value", then we add it to
+      // the breadcrumbs and remove it from the parts that still must be
+      // processed.
+      if (static::isDelta($parts[0])) {
+        $reference_breadcrumbs[] = array_shift($parts);
+      }
+
+      // If there are no remaining path parts, the process is finished.
+      if (empty($parts)) {
+        return $this->constructInternalPath($reference_breadcrumbs);
+      }
+
+      // JSON:API outputs entity reference field properties under a meta object
+      // on a relationship. If the filter specifies one of these properties, it
+      // must prefix the property name with `meta`. The only exception is if the
+      // next path part is the same as the name for the reference property
+      // (typically `entity`), this is permitted to disambiguate the case of a
+      // field name on the target entity which is the same a property name on
+      // the entity reference field.
+      if ($at_least_one_entity_reference_field && $parts[0] !== 'id') {
+        if ($parts[0] === 'meta') {
+          array_shift($parts);
+        }
+        elseif (in_array($parts[0], $candidate_property_names) && !static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
+          throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s` belongs to the meta object of a relationship and must be preceded by `meta`.', $parts[0], $external_field_name));
+        }
+      }
+
+      // Determine if the next part is not a property of $field_name.
+      if (!static::isCandidateDefinitionProperty($parts[0], $candidate_definitions) && !empty(static::getAllDataReferencePropertyNames($candidate_definitions))) {
+        // The next path part is neither a delta nor a field property, so it
+        // must be a field on a targeted resource type. We need to guess the
+        // intermediate reference property since one was not provided.
+        //
+        // For example, the path `uid.name` for a `node--article` resource type
+        // will be resolved into `uid.entity.name`.
+        $reference_breadcrumbs[] = static::getDataReferencePropertyName($candidate_definitions, $parts, $unresolved_path_parts);
+      }
+      else {
+        // If the property is not a reference property, then all
+        // remaining parts must be further property specifiers.
+        if (!static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
+          // If a field property is specified on a field with only one property
+          // defined, throw an error because in the JSON:API output, it does not
+          // exist. This is because JSON:API elides single-value properties;
+          // respecting it would leak this Drupalism out.
+          if (count($candidate_property_names) === 1) {
+            throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Filter by `%s`, not `%s` (the JSON:API module elides property names from single-property fields).', $parts[0], $external_field_name, substr($external_field_name, 0, strlen($external_field_name) - strlen($parts[0]) - 1), $external_field_name));
+          }
+          elseif (!in_array($parts[0], $candidate_property_names, TRUE)) {
+            throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Must be one of the following property names: `%s`.', $parts[0], $external_field_name, implode('`, `', $candidate_property_names)));
+          }
+          return $this->constructInternalPath($reference_breadcrumbs, $parts);
+        }
+        // The property is a reference, so add it to the breadcrumbs and
+        // continue resolving fields.
+        $reference_breadcrumbs[] = array_shift($parts);
+      }
+    }
+
+    // Reconstruct the full path to the final reference field.
+    return $this->constructInternalPath($reference_breadcrumbs);
+  }
+
+  /**
+   * Expands the internal path with the "entity" keyword.
+   *
+   * @param string[] $references
+   *   The resolved internal field names of all entity references.
+   * @param string[] $property_path
+   *   (optional) A sub-property path for the last field in the path.
+   *
+   * @return string
+   *   The expanded and imploded path.
+   */
+  protected function constructInternalPath(array $references, array $property_path = []) {
+    // Reconstruct the path parts that are referencing sub-properties.
+    $field_path = implode('.', $property_path);
+
+    // This rebuilds the path from the real, internal field names that have
+    // been traversed so far. It joins them with the "entity" keyword as
+    // required by the entity query system.
+    $entity_path = implode('.', $references);
+
+    // Reconstruct the full path to the final reference field.
+    return (empty($field_path)) ? $entity_path : $entity_path . '.' . $field_path;
+  }
+
+  /**
+   * Get all item definitions from a set of resources types by a field name.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resource types on which the field might exist.
+   * @param string $field_name
+   *   The field for which to retrieve field item definitions.
+   *
+   * @return \Drupal\Core\TypedData\ComplexDataDefinitionInterface[]
+   *   The found field item definitions.
+   */
+  protected function getFieldItemDefinitions(array $resource_types, $field_name) {
+    return array_reduce($resource_types, function ($result, ResourceType $resource_type) use ($field_name) {
+      /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+      $entity_type = $resource_type->getEntityTypeId();
+      $bundle = $resource_type->getBundle();
+      $definitions = $this->fieldManager->getFieldDefinitions($entity_type, $bundle);
+      if (isset($definitions[$field_name])) {
+        $result[$resource_type->getTypeName()] = $definitions[$field_name]->getItemDefinition();
+      }
+      return $result;
+    }, []);
+  }
+
+  /**
+   * Resolves the UUID field name for a resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which to get the UUID field name.
+   *
+   * @return string
+   *   The resolved internal name.
+   */
+  protected function getIdFieldName(ResourceType $resource_type) {
+    $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
+    return $entity_type->getKey('uuid');
+  }
+
+  /**
+   * Resolves the internal field name based on a collection of resource types.
+   *
+   * @param string $field_name
+   *   The external field name.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resource types from which to get an internal name.
+   *
+   * @return string
+   *   The resolved internal name.
+   */
+  protected function getInternalName($field_name, array $resource_types) {
+    return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($field_name) {
+      if ($carry != $field_name) {
+        // We already found the internal name.
+        return $carry;
+      }
+      return $field_name === 'id' ? $this->getIdFieldName($resource_type) : $resource_type->getInternalName($field_name);
+    }, $field_name);
+  }
+
+  /**
+   * Determines if the given field or member name is filterable.
+   *
+   * @param string $external_name
+   *   The external field or member name.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resource types to test.
+   *
+   * @return bool
+   *   Whether the given field is present as a filterable member of the targeted
+   *   resource objects.
+   */
+  protected function isMemberFilterable($external_name, array $resource_types) {
+    return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($external_name) {
+      // @todo: remove the next line and uncomment the following one in https://www.drupal.org/project/jsonapi/issues/3017047.
+      return $carry ?: $external_name === 'id' || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));
+      /*return $carry ?: in_array($external_name, ['id', 'type']) || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));*/
+    }, FALSE);
+  }
+
+  /**
+   * Get the referenceable ResourceTypes for a set of field definitions.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface[] $definitions
+   *   The resource types on which the reference field might exist.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The referenceable target resource types.
+   */
+  protected function getReferenceableResourceTypes(array $definitions) {
+    return array_reduce($definitions, function ($result, $definition) {
+      $resource_types = array_filter(
+        $this->collectResourceTypesForReference($definition)
+      );
+      $type_names = array_map(function ($resource_type) {
+        /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+        return $resource_type->getTypeName();
+      }, $resource_types);
+      return array_merge($result, array_combine($type_names, $resource_types));
+    }, []);
+  }
+
+  /**
+   * Build a list of resource types depending on which bundles are referenced.
+   *
+   * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface $item_definition
+   *   The reference definition.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The list of resource types.
+   */
+  protected function collectResourceTypesForReference(FieldItemDataDefinitionInterface $item_definition) {
+    $main_property_definition = $item_definition->getPropertyDefinition(
+      $item_definition->getMainPropertyName()
+    );
+
+    // Check if the field is a flavor of an Entity Reference field.
+    if (!$main_property_definition instanceof DataReferenceTargetDefinition) {
+      return [];
+    }
+    $entity_type_id = $item_definition->getSetting('target_type');
+    $handler_settings = $item_definition->getSetting('handler_settings');
+
+    $has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']);
+    $target_bundles = $has_target_bundles ?
+      $handler_settings['target_bundles']
+      : $this->getAllBundlesForEntityType($entity_type_id);
+
+    return array_map(function ($bundle) use ($entity_type_id) {
+      return $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    }, $target_bundles);
+  }
+
+  /**
+   * Whether the given resources can be traversed to other resources.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resources types to evaluate.
+   *
+   * @return bool
+   *   TRUE if any one of the given resource types is traversable.
+   *
+   * @todo This class shouldn't be aware of entity types and their definitions.
+   * Whether a resource can have relationships to other resources is information
+   * we ought to be able to discover on the ResourceType. However, we cannot
+   * reliably determine this information with existing APIs. Entities may be
+   * backed by various storages that are unable to perform queries across
+   * references and certain storages may not be able to store references at all.
+   */
+  protected function resourceTypesAreTraversable(array $resource_types) {
+    foreach ($resource_types as $resource_type) {
+      $entity_type_definition = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
+      if ($entity_type_definition->entityClassImplements(FieldableEntityInterface::class)) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Gets all bundle IDs for a given entity type.
+   *
+   * @param string $entity_type_id
+   *   The entity type for which to get bundles.
+   *
+   * @return string[]
+   *   The bundle IDs.
+   */
+  protected function getAllBundlesForEntityType($entity_type_id) {
+    return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
+  }
+
+  /**
+   * Gets all unique reference property names from the given field definitions.
+   *
+   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
+   *   A list of targeted field item definitions specified by the path.
+   *
+   * @return string[]
+   *   The reference property names, if any.
+   */
+  protected static function getAllDataReferencePropertyNames(array $candidate_definitions) {
+    $reference_property_names = array_reduce($candidate_definitions, function (array $reference_property_names, ComplexDataDefinitionInterface $definition) {
+      $property_definitions = $definition->getPropertyDefinitions();
+      foreach ($property_definitions as $property_name => $property_definition) {
+        if ($property_definition instanceof DataReferenceDefinitionInterface) {
+          $target_definition = $property_definition->getTargetDefinition();
+          assert($target_definition instanceof EntityDataDefinitionInterface, 'Entity reference fields should only be able to reference entities.');
+          $reference_property_names[] = $property_name . ':' . $target_definition->getEntityTypeId();
+        }
+      }
+      return $reference_property_names;
+    }, []);
+    return array_unique($reference_property_names);
+  }
+
+  /**
+   * Determines the reference property name for the remaining unresolved parts.
+   *
+   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
+   *   A list of targeted field item definitions specified by the path.
+   * @param string[] $remaining_parts
+   *   The remaining path parts.
+   * @param string[] $unresolved_path_parts
+   *   The unresolved path parts.
+   *
+   * @return string
+   *   The reference name.
+   */
+  protected static function getDataReferencePropertyName(array $candidate_definitions, array $remaining_parts, array $unresolved_path_parts) {
+    $unique_reference_names = static::getAllDataReferencePropertyNames($candidate_definitions);
+    if (count($unique_reference_names) > 1) {
+      $choices = array_map(function ($reference_name) use ($unresolved_path_parts, $remaining_parts) {
+        $prior_parts = array_slice($unresolved_path_parts, 0, count($unresolved_path_parts) - count($remaining_parts));
+        return implode('.', array_merge($prior_parts, [$reference_name], $remaining_parts));
+      }, $unique_reference_names);
+      // @todo Add test coverage for this in https://www.drupal.org/project/jsonapi/issues/2971281
+      $message = sprintf('Ambiguous path. Try one of the following: %s, in place of the given path: %s', implode(', ', $choices), implode('.', $unresolved_path_parts));
+      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
+      throw new CacheableBadRequestHttpException($cacheability, $message);
+    }
+    return $unique_reference_names[0];
+  }
+
+  /**
+   * Determines if a path part targets a specific field delta.
+   *
+   * @param string $part
+   *   The path part.
+   *
+   * @return bool
+   *   TRUE if the part is an integer, FALSE otherwise.
+   */
+  protected static function isDelta($part) {
+    return (bool) preg_match('/^[0-9]+$/', $part);
+  }
+
+  /**
+   * Determines if a path part targets a field property, not a subsequent field.
+   *
+   * @param string $part
+   *   The path part.
+   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
+   *   A list of targeted field item definitions which are specified by the
+   *   path.
+   *
+   * @return bool
+   *   TRUE if the part is a property of one of the candidate definitions, FALSE
+   *   otherwise.
+   */
+  protected static function isCandidateDefinitionProperty($part, array $candidate_definitions) {
+    $part = static::getPathPartPropertyName($part);
+    foreach ($candidate_definitions as $definition) {
+      if ($definition->getPropertyDefinition($part)) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Determines if a path part targets a reference property.
+   *
+   * @param string $part
+   *   The path part.
+   * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
+   *   A list of targeted field item definitions which are specified by the
+   *   path.
+   *
+   * @return bool
+   *   TRUE if the part is a property of one of the candidate definitions, FALSE
+   *   otherwise.
+   */
+  protected static function isCandidateDefinitionReferenceProperty($part, array $candidate_definitions) {
+    $part = static::getPathPartPropertyName($part);
+    foreach ($candidate_definitions as $definition) {
+      $property = $definition->getPropertyDefinition($part);
+      if ($property && $property instanceof DataReferenceDefinitionInterface) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Gets the property name from an entity typed or untyped path part.
+   *
+   * A path part may contain an entity type specifier like `entity:node`. This
+   * extracts the actual property name. If an entity type is not specified, then
+   * the path part is simply returned. For example, both `foo` and `foo:bar`
+   * will return `foo`.
+   *
+   * @param string $part
+   *   A path part.
+   *
+   * @return string
+   *   The property name from a path part.
+   */
+  protected static function getPathPartPropertyName($part) {
+    return strpos($part, ':') !== FALSE ? explode(':', $part)[0] : $part;
+  }
+
+  /**
+   * Gets the field access result for the 'view' operation.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type on which the field exists.
+   * @param string $internal_field_name
+   *   The field name for which access should be checked.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The 'view' access result.
+   */
+  protected function getFieldAccess(ResourceType $resource_type, $internal_field_name) {
+    $definitions = $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle());
+    assert(isset($definitions[$internal_field_name]), 'The field name should have already been validated.');
+    $field_definition = $definitions[$internal_field_name];
+    $filter_access_results = $this->moduleHandler->invokeAll('jsonapi_entity_field_filter_access', [$field_definition, \Drupal::currentUser()]);
+    $filter_access_result = array_reduce($filter_access_results, function (AccessResultInterface $combined_result, AccessResultInterface $result) {
+      return $combined_result->orIf($result);
+    }, AccessResult::neutral());
+    if (!$filter_access_result->isNeutral()) {
+      return $filter_access_result;
+    }
+    $entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($resource_type->getEntityTypeId());
+    $field_access = $entity_access_control_handler->fieldAccess('view', $field_definition, NULL, NULL, TRUE);
+    return $filter_access_result->orIf($field_access);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php
new file mode 100644
index 0000000000..d652e40e35
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -0,0 +1,1091 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Entity\RevisionableStorageInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\Access\EntityAccessChecker;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Entity\EntityValidationTrait;
+use Drupal\jsonapi\Access\TemporaryQueryGuard;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\IncludeResolver;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+
+/**
+ * Process all entity requests.
+ *
+ * @internal
+ */
+class EntityResource {
+
+  use EntityValidationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The link manager service.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * The include resolver.
+   *
+   * @var \Drupal\jsonapi\IncludeResolver
+   */
+  protected $includeResolver;
+
+  /**
+   * The JSON:API entity access checker.
+   *
+   * @var \Drupal\jsonapi\Access\EntityAccessChecker
+   */
+  protected $entityAccessChecker;
+
+  /**
+   * The JSON:API field resolver.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $fieldResolver;
+
+  /**
+   * Instantiates a EntityResource object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity type field manager.
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager service.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The link manager service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+   *   The entity repository.
+   * @param \Drupal\jsonapi\IncludeResolver $include_resolver
+   *   The include resolver.
+   * @param \Drupal\jsonapi\Access\EntityAccessChecker $entity_access_checker
+   *   The JSON:API entity access checker.
+   * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
+   *   The JSON:API field resolver.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository, RendererInterface $renderer, EntityRepositoryInterface $entity_repository, IncludeResolver $include_resolver, EntityAccessChecker $entity_access_checker, FieldResolver $field_resolver) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->linkManager = $link_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->renderer = $renderer;
+    $this->entityRepository = $entity_repository;
+    $this->includeResolver = $include_resolver;
+    $this->entityAccessChecker = $entity_access_checker;
+    $this->fieldResolver = $field_resolver;
+  }
+
+  /**
+   * Gets the individual entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The loaded entity.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   Thrown when access to the entity is not allowed.
+   */
+  public function getIndividual(EntityInterface $entity, Request $request) {
+    $resource_object = $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
+    if ($resource_object instanceof EntityAccessDeniedHttpException) {
+      throw $resource_object;
+    }
+    $response = $this->buildWrappedResponse($resource_object, $request, $this->getIncludes($request, $resource_object));
+    return $response;
+  }
+
+  /**
+   * Creates an individual entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\EntityInterface $parsed_entity
+   *   The loaded entity.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
+   *   Thrown when the entity already exists.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the entity does not pass validation.
+   */
+  public function createIndividual(ResourceType $resource_type, EntityInterface $parsed_entity, Request $request) {
+    if ($parsed_entity instanceof FieldableEntityInterface) {
+      // Only check 'edit' permissions for fields that were actually submitted
+      // by the user. Field access makes no distinction between 'create' and
+      // 'update', so the 'edit' operation is used here.
+      $document = Json::decode($request->getContent());
+      foreach (['attributes', 'relationships'] as $data_member_name) {
+        if (isset($document['data'][$data_member_name])) {
+          $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) {
+            return $resource_type->getInternalName($public_field_name);
+          }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) {
+            return $resource_type->hasField($internal_field_name);
+          });
+          foreach ($valid_names as $field_name) {
+            $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE);
+            if (!$field_access->isAllowed()) {
+              $public_field_name = $resource_type->getPublicName($field_name);
+              throw new EntityAccessDeniedHttpException(NULL, $field_access, "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', $public_field_name));
+            }
+          }
+        }
+      }
+    }
+
+    static::validate($parsed_entity);
+
+    // Return a 409 Conflict response in accordance with the JSON:API spec. See
+    // http://jsonapi.org/format/#crud-creating-responses-409.
+    if ($this->entityExists($parsed_entity)) {
+      throw new ConflictHttpException('Conflict: Entity already exists.');
+    }
+
+    $parsed_entity->save();
+
+    // Build response object.
+    $resource_object = new ResourceObject($resource_type, $parsed_entity);
+    $response = $this->buildWrappedResponse($resource_object, $request, $this->getIncludes($request, $resource_object), 201);
+
+    // According to JSON:API specification, when a new entity was created
+    // we should send "Location" header to the frontend.
+    if ($resource_type->isLocatable()) {
+      $url = $resource_object->toUrl()->setAbsolute()->toString(TRUE);
+      $response->addCacheableDependency($url);
+      $response->headers->set('Location', $url->getGeneratedUrl());
+    }
+
+    // Return response object with updated headers info.
+    return $response;
+  }
+
+  /**
+   * Patches an individual entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The loaded entity.
+   * @param \Drupal\Core\Entity\EntityInterface $parsed_entity
+   *   The entity with the new data.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when the selected entity does not match the id in th payload.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the patched entity does not pass validation.
+   */
+  public function patchIndividual(ResourceType $resource_type, EntityInterface $entity, EntityInterface $parsed_entity, Request $request) {
+    $body = Json::decode($request->getContent());
+    $data = $body['data'];
+    if ($data['id'] != $entity->uuid()) {
+      throw new BadRequestHttpException(sprintf(
+        'The selected entity (%s) does not match the ID in the payload (%s).',
+        $entity->uuid(),
+        $data['id']
+      ));
+    }
+    $data += ['attributes' => [], 'relationships' => []];
+    $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships']));
+
+    array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) {
+      $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name);
+      return $destination;
+    }, $entity);
+
+    static::validate($entity, $field_names);
+    $entity->save();
+    $resource_object = new ResourceObject($resource_type, $entity);
+    return $this->buildWrappedResponse($resource_object, $request, $this->getIncludes($request, $resource_object));
+  }
+
+  /**
+   * Deletes an individual entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The loaded entity.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  public function deleteIndividual(EntityInterface $entity) {
+    $entity->delete();
+    return new ResourceResponse(NULL, 204);
+  }
+
+  /**
+   * Gets the collection of entities.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the request to be served.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
+   *   Thrown when filtering on a config entity which does not support it.
+   */
+  public function getCollection(ResourceType $resource_type, Request $request) {
+    // Instantiate the query for the filtering.
+    $entity_type_id = $resource_type->getEntityTypeId();
+
+    $params = $this->getJsonApiParams($request, $resource_type);
+    $query_cacheability = new CacheableMetadata();
+    $query = $this->getCollectionQuery($resource_type, $params, $query_cacheability);
+
+    // If the request is for the latest revision, toggle it on entity query.
+    if ($request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE)) {
+      $query->latestRevision();
+    }
+
+    try {
+      $results = $this->executeQueryInRenderContext(
+        $query,
+        $query_cacheability
+      );
+    }
+    catch (\LogicException $e) {
+      // Ensure good DX when an entity query involves a config entity type.
+      // For example: getting users with a particular role, which is a config
+      // entity type: https://www.drupal.org/project/jsonapi/issues/2959445.
+      // @todo Remove the message parsing in https://www.drupal.org/project/drupal/issues/3028967.
+      if (strpos($e->getMessage(), 'Getting the base fields is not supported for entity type') === 0) {
+        preg_match('/entity type (.*)\./', $e->getMessage(), $matches);
+        $config_entity_type_id = $matches[1];
+        $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', 'url.query_args:filter']);
+        throw new CacheableBadRequestHttpException($cacheability, sprintf("Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a %s config entity.", $config_entity_type_id));
+      }
+      else {
+        throw $e;
+      }
+    }
+
+    $storage = $this->entityTypeManager->getStorage($entity_type_id);
+    // We request N+1 items to find out if there is a next page for the pager.
+    // We may need to remove that extra item before loading the entities.
+    $pager_size = $query->getMetaData('pager_size');
+    if ($has_next_page = $pager_size < count($results)) {
+      // Drop the last result.
+      array_pop($results);
+    }
+    // Each item of the collection data contains an array with 'entity' and
+    // 'access' elements.
+    $collection_data = $this->loadEntitiesWithAccess($storage, $results, $request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE));
+    $entity_collection = new EntityCollection($collection_data);
+    $entity_collection->setHasNextPage($has_next_page);
+
+    // Calculate all the results and pass them to the EntityCollectionInterface.
+    $count_query_cacheability = new CacheableMetadata();
+    if ($resource_type->includeCount()) {
+      $count_query = $this->getCollectionCountQuery($resource_type, $params, $count_query_cacheability);
+      $total_results = $this->executeQueryInRenderContext(
+        $count_query,
+        $count_query_cacheability
+      );
+
+      $entity_collection->setTotalCount($total_results);
+    }
+
+    $response = $this->respondWithCollection($entity_collection, $this->getIncludes($request, $entity_collection), $request, $resource_type, $params[OffsetPage::KEY_NAME]);
+
+    $response->addCacheableDependency($query_cacheability);
+    $response->addCacheableDependency($count_query_cacheability);
+    $response->addCacheableDependency((new CacheableMetadata())
+      ->addCacheContexts([
+        'url.query_args:filter',
+        'url.query_args:sort',
+        'url.query_args:page',
+      ]));
+
+    if ($resource_type->isVersionable()) {
+      $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts([ResourceVersionRouteEnhancer::CACHE_CONTEXT]));
+    }
+
+    return $response;
+  }
+
+  /**
+   * Executes the query in a render context, to catch bubbled cacheability.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to execute to get the return results.
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   The value object to carry the query cacheability.
+   *
+   * @return int|array
+   *   Returns an integer for count queries or an array of IDs. The values of
+   *   the array are always entity IDs. The keys will be revision IDs if the
+   *   entity supports revision and entity IDs if not.
+   *
+   * @see node_query_node_access_alter()
+   * @see https://www.drupal.org/project/drupal/issues/2557815
+   * @see https://www.drupal.org/project/drupal/issues/2794385
+   * @todo Remove this after https://www.drupal.org/project/drupal/issues/3028976 is fixed.
+   */
+  protected function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) {
+    $context = new RenderContext();
+    $results = $this->renderer->executeInRenderContext($context, function () use ($query) {
+      return $query->execute();
+    });
+    if (!$context->isEmpty()) {
+      $query_cacheability->addCacheableDependency($context->pop());
+    }
+    return $results;
+  }
+
+  /**
+   * Gets the related resource.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The requested entity.
+   * @param string $related
+   *   The related field name.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  public function getRelated(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) {
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->get($resource_type->getInternalName($related));
+
+    // Remove the entities pointing to a resource that may be disabled. Even
+    // though the normalizer skips disabled references, we can avoid unnecessary
+    // work by checking here too.
+    /* @var \Drupal\Core\Entity\EntityInterface[] $referenced_entities */
+    $referenced_entities = array_filter(
+      $field_list->referencedEntities(),
+      function (EntityInterface $entity) {
+        return (bool) $this->resourceTypeRepository->get(
+          $entity->getEntityTypeId(),
+          $entity->bundle()
+        );
+      }
+    );
+    $collection_data = [];
+    foreach ($referenced_entities as $referenced_entity) {
+      $collection_data[] = $this->entityAccessChecker->getAccessCheckedResourceObject($referenced_entity);
+    }
+    $entity_collection = new EntityCollection($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality());
+    $response = $this->buildWrappedResponse($entity_collection, $request, $this->getIncludes($request, $entity_collection));
+
+    // $response does not contain the entity list cache tag. We add the
+    // cacheable metadata for the finite list of entities in the relationship.
+    $response->addCacheableDependency($entity);
+
+    return $response;
+  }
+
+  /**
+   * Gets the relationship of an entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The requested entity.
+   * @param string $related
+   *   The related field name.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param int $response_code
+   *   The response code. Defaults to 200.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  public function getRelationship(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request, $response_code = 200) {
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->get($resource_type->getInternalName($related));
+    // Access will have already been checked by the RelationshipFieldAccess
+    // service, so we don't need to call ::getAccessCheckedResourceObject().
+    $resource_object = new ResourceObject($resource_type, $entity);
+    $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $resource_object), $response_code);
+    // Add the host entity as a cacheable dependency.
+    $response->addCacheableDependency($entity);
+    return $response;
+  }
+
+  /**
+   * Adds a relationship to a to-many relationship.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The requested entity.
+   * @param string $related
+   *   The related field name.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The received resource identifiers.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   Thrown when the current user is not allowed to PATCH the selected
+   *   field(s).
+   * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
+   *   Thrown when POSTing to a "to-one" relationship.
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown when the underlying entity cannot be saved.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the updated entity does not pass validation.
+   */
+  public function addToRelationshipData(ResourceType $resource_type, FieldableEntityInterface $entity, $related, array $resource_identifiers, Request $request) {
+    $related = $resource_type->getInternalName($related);
+    // According to the specification, you are only allowed to POST to a
+    // relationship if it is a to-many relationship.
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->{$related};
+    /* @var \Drupal\field\Entity\FieldConfig $field_definition */
+    $field_definition = $field_list->getFieldDefinition();
+    $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
+    if (!$is_multiple) {
+      throw new ConflictHttpException(sprintf('You can only POST to to-many relationships. %s is a to-one relationship.', $related));
+    }
+
+    $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
+    $new_resource_identifiers = array_udiff(
+      ResourceIdentifier::deduplicate(array_merge($original_resource_identifiers, $resource_identifiers)),
+      $original_resource_identifiers,
+      [ResourceIdentifier::class, 'compare']
+    );
+
+    // There are no relationships that need to be added so we can exit early.
+    if (empty($new_resource_identifiers)) {
+      $status = static::relationshipResponseRequiresBody($resource_identifiers, $original_resource_identifiers) ? 200 : 204;
+      return $this->getRelationship($resource_type, $entity, $related, $request, $status);
+    }
+
+    $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
+    foreach ($new_resource_identifiers as $new_resource_identifier) {
+      $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()];
+      // Remove `arity` from the received extra properties, otherwise this
+      // will fail field validation.
+      $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
+      $field_list->appendItem($new_field_value);
+    }
+
+    $this->validate($entity);
+    $entity->save();
+
+    $final_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
+    $status = static::relationshipResponseRequiresBody($resource_identifiers, $final_resource_identifiers) ? 200 : 204;
+    return $this->getRelationship($resource_type, $entity, $related, $request, $status);
+  }
+
+  /**
+   * Updates the relationship of an entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related
+   *   The related field name.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The client-sent resource identifiers which should be set on the given
+   *   entity.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown when the underlying entity cannot be saved.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the updated entity does not pass validation.
+   */
+  public function replaceRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, array $resource_identifiers, Request $request) {
+    $related = $resource_type->getInternalName($related);
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $resource_identifiers */
+    // According to the specification, PATCH works a little bit different if the
+    // relationship is to-one or to-many.
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->{$related};
+    $field_definition = $field_list->getFieldDefinition();
+    $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
+    $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship';
+    $this->{$method}($entity, $resource_identifiers, $field_definition);
+    $this->validate($entity);
+    $entity->save();
+    $requires_response = static::relationshipResponseRequiresBody($resource_identifiers, ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list));
+    return $this->getRelationship($resource_type, $entity, $related, $request, $requires_response ? 200 : 204);
+  }
+
+  /**
+   * Update a to-one relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The client-sent resource identifiers which should be set on the given
+   *   entity. Should be an empty array or an array with a single value.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition of the entity field to be updated.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when a "to-one" relationship is not provided.
+   */
+  protected function doPatchIndividualRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) {
+    if (count($resource_identifiers) > 1) {
+      throw new BadRequestHttpException(sprintf('Provide a single relationship so to-one relationship fields (%s).', $field_definition->getName()));
+    }
+    $this->doPatchMultipleRelationship($entity, $resource_identifiers, $field_definition);
+  }
+
+  /**
+   * Update a to-many relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The client-sent resource identifiers which should be set on the given
+   *   entity.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition of the entity field to be updated.
+   */
+  protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) {
+    $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName();
+    $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) {
+      $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()];
+      // Remove `arity` from the received extra properties, otherwise this
+      // will fail field validation.
+      $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY]));
+      return $field_properties;
+    }, $resource_identifiers);
+  }
+
+  /**
+   * Deletes the relationship of an entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the request to be served.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related
+   *   The related field name.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[]|\Symfony\Component\HttpFoundation\Request $resource_identifiers
+   *   The client-sent resource identifiers which should be removed from the
+   *   relationship, if they exist.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when not body was provided for the DELETE operation.
+   * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
+   *   Thrown when deleting a "to-one" relationship.
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown when the underlying entity cannot be saved.
+   */
+  public function removeFromRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, array $resource_identifiers, Request $request) {
+    if ($resource_identifiers instanceof Request) {
+      // This usually means that there was not body provided.
+      throw new BadRequestHttpException(sprintf('You need to provide a body for DELETE operations on a relationship (%s).', $related));
+    }
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->{$related};
+    $is_multiple = $field_list->getFieldDefinition()
+      ->getFieldStorageDefinition()
+      ->isMultiple();
+    if (!$is_multiple) {
+      throw new ConflictHttpException(sprintf('You can only DELETE from to-many relationships. %s is a to-one relationship.', $related));
+    }
+
+    // Compute the list of current values and remove the ones in the payload.
+    $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list);
+    $removed_resource_identifiers = array_uintersect($resource_identifiers, $original_resource_identifiers, [ResourceIdentifier::class, 'compare']);
+    $deltas_to_be_removed = [];
+    foreach ($removed_resource_identifiers as $removed_resource_identifier) {
+      foreach ($original_resource_identifiers as $delta => $existing_resource_identifier) {
+        // Identify the field item deltas which should be removed.
+        if (ResourceIdentifier::isDuplicate($removed_resource_identifier, $existing_resource_identifier)) {
+          $deltas_to_be_removed[] = $delta;
+        }
+      }
+    }
+    // Field item deltas are reset when an item is removed. This removes
+    // items in descending order so that the deltas yet to be removed will
+    // continue to exist.
+    rsort($deltas_to_be_removed);
+    foreach ($deltas_to_be_removed as $delta) {
+      $field_list->removeItem($delta);
+    }
+
+    // Save the entity and return the response object.
+    static::validate($entity);
+    $entity->save();
+    return $this->getRelationship($resource_type, $entity, $related, $request, 204);
+  }
+
+  /**
+   * Gets a basic query for a collection.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the query.
+   * @param array $params
+   *   The parameters for the query.
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   Collects cacheability for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   */
+  protected function getCollectionQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) {
+    $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
+    $entity_storage = $this->entityTypeManager->getStorage($resource_type->getEntityTypeId());
+
+    $query = $entity_storage->getQuery();
+
+    // Ensure that access checking is performed on the query.
+    $query->accessCheck(TRUE);
+
+    // Compute and apply an entity query condition from the filter parameter.
+    if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) {
+      $query->condition($filter->queryCondition($query));
+      TemporaryQueryGuard::setFieldManager($this->fieldManager);
+      TemporaryQueryGuard::setModuleHandler(\Drupal::moduleHandler());
+      TemporaryQueryGuard::applyAccessControls($filter, $query, $query_cacheability);
+    }
+
+    // Apply any sorts to the entity query.
+    if (isset($params[Sort::KEY_NAME]) && $sort = $params[Sort::KEY_NAME]) {
+      foreach ($sort->fields() as $field) {
+        $path = $this->fieldResolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $field[Sort::PATH_KEY]);
+        $direction = isset($field[Sort::DIRECTION_KEY]) ? $field[Sort::DIRECTION_KEY] : 'ASC';
+        $langcode = isset($field[Sort::LANGUAGE_KEY]) ? $field[Sort::LANGUAGE_KEY] : NULL;
+        $query->sort($path, $direction, $langcode);
+      }
+    }
+
+    // Apply any pagination options to the query.
+    if (isset($params[OffsetPage::KEY_NAME])) {
+      $pagination = $params[OffsetPage::KEY_NAME];
+    }
+    else {
+      $pagination = new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX);
+    }
+    // Add one extra element to the page to see if there are more pages needed.
+    $query->range($pagination->getOffset(), $pagination->getSize() + 1);
+    $query->addMetaData('pager_size', (int) $pagination->getSize());
+
+    // Limit this query to the bundle type for this resource.
+    $bundle = $resource_type->getBundle();
+    if ($bundle && ($bundle_key = $entity_type->getKey('bundle'))) {
+      $query->condition(
+        $bundle_key, $bundle
+      );
+    }
+
+    return $query;
+  }
+
+  /**
+   * Gets a basic query for a collection count.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the query.
+   * @param array $params
+   *   The parameters for the query.
+   * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability
+   *   Collects cacheability for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   */
+  protected function getCollectionCountQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) {
+    // Reset the range to get all the available results.
+    return $this->getCollectionQuery($resource_type, $params, $query_cacheability)->range()->count();
+  }
+
+  /**
+   * Loads the entity targeted by a resource identifier.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $resource_identifier
+   *   A resource identifier.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity targeted by a resource identifier.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown if the given resource identifier targets a resource type or
+   *   resource which does not exist.
+   */
+  protected function getEntityFromResourceIdentifier(ResourceIdentifier $resource_identifier) {
+    $resource_type_name = $resource_identifier->getTypeName();
+    if (!($target_resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name))) {
+      throw new BadRequestHttpException("The resource type `{$resource_type_name}` does not exist.");
+    }
+    $id = $resource_identifier->getId();
+    if (!($targeted_resource = $this->entityRepository->loadEntityByUuid($target_resource_type->getEntityTypeId(), $id))) {
+      throw new BadRequestHttpException("The targeted `{$resource_type_name}` resource with ID `{$id}` does not exist.");
+    }
+    return $targeted_resource;
+  }
+
+  /**
+   * Determines if the client needs to be updated with new relationship data.
+   *
+   * @param array $received_resource_identifiers
+   *   The array of resource identifiers given by the client.
+   * @param array $final_resource_identifiers
+   *   The final array of resource identifiers after applying the requested
+   *   changes.
+   *
+   * @return bool
+   *   Whether the final array of resource identifiers is different than the
+   *   client-sent data.
+   */
+  protected static function relationshipResponseRequiresBody(array $received_resource_identifiers, array $final_resource_identifiers) {
+    return !empty(array_udiff($final_resource_identifiers, $received_resource_identifiers, [ResourceIdentifier::class, 'compare']));
+  }
+
+  /**
+   * Builds a response with the appropriate wrapped document.
+   *
+   * @param mixed $data
+   *   The data to wrap.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $includes
+   *   The resources to be included in the document. Use NullEntityCollection if
+   *   there should be no included resources in the document.
+   * @param int $response_code
+   *   The response code.
+   * @param array $headers
+   *   An array of response headers.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   The URLs to which to link. A 'self' link is added automatically.
+   * @param array $meta
+   *   (optional) The top-level metadata.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  protected function buildWrappedResponse($data, Request $request, EntityCollection $includes, $response_code = 200, array $headers = [], LinkCollection $links = NULL, array $meta = []) {
+    $self_link = new Link(new CacheableMetadata(), $this->linkManager->getRequestLink($request), ['self']);
+    $links = ($links ?: new LinkCollection([]));
+    $links = $links->withLink('self', $self_link);
+    $response = new ResourceResponse(new JsonApiDocumentTopLevel($data, $includes, $links, $meta), $response_code, $headers);
+    $cacheability = (new CacheableMetadata())->addCacheContexts([
+      // Make sure that different sparse fieldsets are cached differently.
+      'url.query_args:fields',
+      // Make sure that different sets of includes are cached differently.
+      'url.query_args:include',
+    ]);
+    $response->addCacheableDependency($cacheability);
+    return $response;
+  }
+
+  /**
+   * Respond with an entity collection.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entity_collection
+   *   The collection of entities.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $includes
+   *   The resources to be included in the document.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The base JSON:API resource type for the request to be served.
+   * @param \Drupal\jsonapi\Query\OffsetPage $page_param
+   *   The pagination parameter for the requested collection.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  protected function respondWithCollection(EntityCollection $entity_collection, EntityCollection $includes, Request $request, ResourceType $resource_type, OffsetPage $page_param) {
+    $link_context = [
+      'has_next_page' => $entity_collection->hasNextPage(),
+    ];
+    $meta = [];
+    if ($resource_type->includeCount()) {
+      $link_context['total_count'] = $meta['count'] = $entity_collection->getTotalCount();
+    }
+    $collection_links = $this->linkManager->getPagerLinks(\Drupal::request(), $page_param, $link_context);
+    $response = $this->buildWrappedResponse($entity_collection, $request, $includes, 200, [], $collection_links, $meta);
+
+    // When a new change to any entity in the resource happens, we cannot ensure
+    // the validity of this cached list. Add the list tag to deal with that.
+    $list_tag = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId())
+      ->getListCacheTags();
+    $response->getCacheableMetadata()->addCacheTags($list_tag);
+    foreach ($entity_collection as $entity) {
+      $response->addCacheableDependency($entity);
+    }
+    return $response;
+  }
+
+  /**
+   * Takes a field from the origin entity and puts it to the destination entity.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type of the entity to be updated.
+   * @param \Drupal\Core\Entity\EntityInterface $origin
+   *   The entity that contains the field values.
+   * @param \Drupal\Core\Entity\EntityInterface $destination
+   *   The entity that needs to be updated.
+   * @param string $field_name
+   *   The name of the field to extract and update.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when the serialized and destination entities are of different
+   *   types.
+   */
+  protected function updateEntityField(ResourceType $resource_type, EntityInterface $origin, EntityInterface $destination, $field_name) {
+    // The update is different for configuration entities and content entities.
+    if ($origin instanceof ContentEntityInterface && $destination instanceof ContentEntityInterface) {
+      // First scenario: both are content entities.
+      $field_name = $resource_type->getInternalName($field_name);
+      $destination_field_list = $destination->get($field_name);
+
+      $origin_field_list = $origin->get($field_name);
+      if ($this->checkPatchFieldAccess($destination_field_list, $origin_field_list)) {
+        $destination->set($field_name, $origin_field_list->getValue());
+      }
+    }
+    elseif ($origin instanceof ConfigEntityInterface && $destination instanceof ConfigEntityInterface) {
+      // Second scenario: both are config entities.
+      $destination->set($field_name, $origin->get($field_name));
+    }
+    else {
+      throw new BadRequestHttpException('The serialized entity and the destination entity are of different types.');
+    }
+  }
+
+  /**
+   * Gets includes for the given response data.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
+   *   The response data from which to resolve includes.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   An EntityCollection to be included or a NullEntityCollection if the
+   *   request does not specify any include paths.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  public function getIncludes(Request $request, $data) {
+    return $request->query->has('include') && ($include_parameter = $request->query->get('include')) && !empty($include_parameter)
+      ? $this->includeResolver->resolve($data, $include_parameter)
+      : new NullEntityCollection();
+  }
+
+  /**
+   * Checks whether the given field should be PATCHed.
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface $original_field
+   *   The original (stored) value for the field.
+   * @param \Drupal\Core\Field\FieldItemListInterface $received_field
+   *   The received value for the field.
+   *
+   * @return bool
+   *   Whether the field should be PATCHed or not.
+   *
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   Thrown when the user sending the request is not allowed to update the
+   *   field. Only thrown when the user could not abuse this information to
+   *   determine the stored value.
+   *
+   * @internal
+   *
+   * @see \Drupal\rest\Plugin\rest\resource\EntityResource::checkPatchFieldAccess()
+   */
+  protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
+    // If the user is allowed to edit the field, it is always safe to set the
+    // received value. We may be setting an unchanged value, but that is ok.
+    $field_edit_access = $original_field->access('edit', NULL, TRUE);
+    if ($field_edit_access->isAllowed()) {
+      return TRUE;
+    }
+
+    // The user might not have access to edit the field, but still needs to
+    // submit the current field value as part of the PATCH request. For
+    // example, the entity keys required by denormalizers. Therefore, if the
+    // received value equals the stored value, return FALSE without throwing an
+    // exception. But only for fields that the user has access to view, because
+    // the user has no legitimate way of knowing the current value of fields
+    // that they are not allowed to view, and we must not make the presence or
+    // absence of a 403 response a way to find that out.
+    if ($original_field->access('view') && $original_field->equals($received_field)) {
+      return FALSE;
+    }
+
+    // It's helpful and safe to let the user know when they are not allowed to
+    // update a field.
+    $field_name = $received_field->getName();
+    throw new EntityAccessDeniedHttpException($original_field->getEntity(), $field_edit_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name));
+  }
+
+  /**
+   * Build a collection of the entities to respond with and access objects.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage to load the entities from.
+   * @param int[] $ids
+   *   An array of entity IDs, keyed by revision ID if the entity type is
+   *   revisionable.
+   * @param bool $load_latest_revisions
+   *   Whether to load the latest revisions instead of the defaults.
+   *
+   * @return array
+   *   An array of loaded entities and/or an access exceptions.
+   */
+  protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids, $load_latest_revisions) {
+    $output = [];
+    if ($load_latest_revisions) {
+      assert($storage instanceof RevisionableStorageInterface);
+      $entities = $storage->loadMultipleRevisions(array_keys($ids));
+    }
+    else {
+      $entities = $storage->loadMultiple($ids);
+    }
+    foreach ($entities as $entity) {
+      $output[$entity->id()] = $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
+    }
+    return array_values($output);
+  }
+
+  /**
+   * Checks if the given entity exists.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to test existence.
+   *
+   * @return bool
+   *   Whether the entity already has been created.
+   */
+  protected function entityExists(EntityInterface $entity) {
+    $entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+    return !empty($entity_storage->loadByProperties([
+      'uuid' => $entity->uuid(),
+    ]));
+  }
+
+  /**
+   * Extracts JSON:API query parameters from the request.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type.
+   *
+   * @return array
+   *   An array of JSON:API parameters like `sort` and `filter`.
+   */
+  protected function getJsonApiParams(Request $request, ResourceType $resource_type) {
+    if ($request->query->has('filter')) {
+      $params[Filter::KEY_NAME] = Filter::createFromQueryParameter($request->query->get('filter'), $resource_type, $this->fieldResolver);
+    }
+    if ($request->query->has('sort')) {
+      $params[Sort::KEY_NAME] = Sort::createFromQueryParameter($request->query->get('sort'));
+    }
+    if ($request->query->has('page')) {
+      $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter($request->query->get('page'));
+    }
+    else {
+      $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter(['page' => ['offset' => OffsetPage::DEFAULT_OFFSET, 'limit' => OffsetPage::SIZE_MAX]]);
+    }
+    return $params;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/EntryPoint.php b/core/modules/jsonapi/src/Controller/EntryPoint.php
new file mode 100644
index 0000000000..3f021a52b6
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntryPoint.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\user\Entity\User;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Exception\RouteNotFoundException;
+
+/**
+ * Controller for the API entry point.
+ *
+ * @internal
+ */
+class EntryPoint extends ControllerBase {
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The account object.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $user;
+
+  /**
+   * EntryPoint constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The resource type repository.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The current user.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, AccountInterface $user) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->user = $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('jsonapi.resource_type.repository'),
+      $container->get('current_user')
+    );
+  }
+
+  /**
+   * Controller to list all the resources.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response object.
+   */
+  public function index() {
+    $cacheability = (new CacheableMetadata())
+      ->addCacheContexts(['user.roles:authenticated'])
+      ->addCacheTags(['jsonapi_resource_types']);
+
+    // Only build URLs for exposed resources.
+    $resources = array_filter($this->resourceTypeRepository->all(), function ($resource) {
+      return !$resource->isInternal();
+    });
+
+    $self_link = new Link(new CacheableMetadata(), Url::fromRoute('jsonapi.resource_list'), ['self']);
+    $urls = array_reduce($resources, function (LinkCollection $carry, ResourceType $resource_type) {
+      if ($resource_type->isLocatable() || $resource_type->isMutable()) {
+        $route_suffix = $resource_type->isLocatable() ? 'collection' : 'collection.post';
+        $url = Url::fromRoute(sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $route_suffix))->setAbsolute();
+        // @todo: implement an extension relation type to signal that this is a primary collection resource.
+        $link_relation_types = [];
+        return $carry->withLink($resource_type->getTypeName(), new Link(new CacheableMetadata(), $url, $link_relation_types));
+      }
+      return $carry;
+    }, new LinkCollection(['self' => $self_link]));
+
+    $meta = [];
+    if ($this->user->isAuthenticated()) {
+      $current_user_uuid = User::load($this->user->id())->uuid();
+      $meta['links']['me'] = ['meta' => ['id' => $current_user_uuid]];
+      $cacheability->addCacheContexts(['user']);
+      try {
+        $me_url = Url::fromRoute(
+          'jsonapi.user--user.individual',
+          ['entity' => $current_user_uuid]
+        )
+          ->setAbsolute()
+          ->toString(TRUE);
+        $meta['links']['me']['href'] = $me_url->getGeneratedUrl();
+        // The cacheability of the `me` URL is the cacheability of that URL
+        // itself and the currently authenticated user.
+        $cacheability = $cacheability->merge($me_url);
+      }
+      catch (RouteNotFoundException $e) {
+        // Do not add the link if the route is disabled or marked as internal.
+      }
+    }
+
+    $response = new ResourceResponse(new JsonApiDocumentTopLevel(new EntityCollection([]), new NullEntityCollection(), $urls, $meta));
+    return $response->addCacheableDependency($cacheability);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/FileUpload.php b/core/modules/jsonapi/src/Controller/FileUpload.php
new file mode 100644
index 0000000000..0fbc0c8ba1
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/FileUpload.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Entity\EntityValidationTrait;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ForwardCompatibility\FileFieldUploader;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\Validator\ConstraintViolationInterface;
+
+/**
+ * Handles file upload requests.
+ *
+ * @internal
+ */
+class FileUpload {
+
+  use EntityValidationTrait;
+
+  /**
+   * The current user making the request.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The file uploader.
+   *
+   * @var \Drupal\jsonapi\ForwardCompatibility\FileFieldUploader
+   */
+  protected $fileUploader;
+
+  /**
+   * An HTTP kernel for making subrequests.
+   *
+   * @var \Symfony\Component\HttpKernel\HttpKernelInterface
+   */
+  protected $httpKernel;
+
+  /**
+   * Creates a new FileUpload instance.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\jsonapi\ForwardCompatibility\FileFieldUploader $file_uploader
+   *   The file uploader.
+   * @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
+   *   An HTTP kernel for making subrequests.
+   */
+  public function __construct(AccountInterface $current_user, EntityFieldManagerInterface $field_manager, FileFieldUploader $file_uploader, HttpKernelInterface $http_kernel) {
+    $this->currentUser = $current_user;
+    $this->fieldManager = $field_manager;
+    $this->fileUploader = $file_uploader;
+    $this->httpKernel = $http_kernel;
+  }
+
+  /**
+   * Handles JSON:API file upload requests.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the current request.
+   * @param string $file_field_name
+   *   The file field for which the file is to be uploaded.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The entity for which the file is to be uploaded.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response object.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
+   *   Thrown when there are validation errors.
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown if the upload's target resource could not be saved.
+   * @throws \Exception
+   *   Thrown if an exception occurs during a subrequest to fetch the newly
+   *   created file entity.
+   */
+  public function handleFileUploadForExistingResource(Request $request, ResourceType $resource_type, $file_field_name, FieldableEntityInterface $entity) {
+    $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
+
+    static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity);
+
+    $filename = FileFieldUploader::validateAndParseContentDispositionHeader($request);
+    $stream = FileFieldUploader::getUploadStream();
+    $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser);
+    fclose($stream);
+
+    if ($file instanceof EntityConstraintViolationListInterface) {
+      $violations = $file;
+      $message = "Unprocessable Entity: file validation failed.\n";
+      $message .= implode("\n", array_map(function (ConstraintViolationInterface $violation) {
+        return PlainTextOutput::renderFromHtml($violation->getMessage());
+      }, (array) $violations->getIterator()));
+      throw new UnprocessableEntityHttpException($message);
+    }
+
+    if ($field_definition->getFieldStorageDefinition()->getCardinality() === 1) {
+      $entity->{$file_field_name} = $file;
+    }
+    else {
+      $entity->get($file_field_name)->appendItem($file);
+    }
+    static::validate($entity, [$file_field_name]);
+    $entity->save();
+
+    $route_parameters = ['entity' => $entity->uuid()];
+    $route_name = sprintf('jsonapi.%s.%s.related', $resource_type->getTypeName(), $file_field_name);
+    $related_url = Url::fromRoute($route_name, $route_parameters)->toString(TRUE);
+    $request = Request::create($related_url->getGeneratedUrl(), 'GET', [], $request->cookies->all(), [], $request->server->all());
+    return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
+  }
+
+  /**
+   * Handles JSON:API file upload requests.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the current request.
+   * @param string $file_field_name
+   *   The file field for which the file is to be uploaded.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response object.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
+   *   Thrown when there are validation errors.
+   */
+  public function handleFileUploadForNewResource(Request $request, ResourceType $resource_type, $file_field_name) {
+    $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
+
+    static::ensureFileUploadAccess($this->currentUser, $field_definition);
+
+    $filename = FileFieldUploader::validateAndParseContentDispositionHeader($request);
+    $stream = FileFieldUploader::getUploadStream();
+    $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser);
+    fclose($stream);
+
+    if ($file instanceof EntityConstraintViolationListInterface) {
+      $violations = $file;
+      $message = "Unprocessable Entity: file validation failed.\n";
+      $message .= implode("\n", array_map(function (ConstraintViolationInterface $violation) {
+        return PlainTextOutput::renderFromHtml($violation->getMessage());
+      }, iterator_to_array($violations)));
+      throw new UnprocessableEntityHttpException($message);
+    }
+
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $self_link = new Link(new CacheableMetadata(), Url::fromRoute('jsonapi.file--file.individual', ['entity' => $file->uuid()]), ['self']);
+    /* $self_link = new Link(new CacheableMetadata(), $this->entity->toUrl('jsonapi'), ['self']); */
+    $links = new LinkCollection(['self' => $self_link]);
+
+    $relatable_resource_types = $resource_type->getRelatableResourceTypesByField($file_field_name);
+    $file_resource_type = reset($relatable_resource_types);
+    return new ResourceResponse(new JsonApiDocumentTopLevel(new ResourceObject($file_resource_type, $file), new NullEntityCollection(), $links), 201, []);
+  }
+
+  /**
+   * Ensures that the given account is allowed to upload a file.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which access should be checked.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field for which the file is to be uploaded.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
+   *   The entity, if one exists, for which the file is to be uploaded.
+   */
+  protected static function ensureFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, FieldableEntityInterface $entity = NULL) {
+    $access_result = $entity
+      ? FileFieldUploader::checkFileUploadAccess($account, $field_definition, $entity)
+      : FileFieldUploader::checkFileUploadAccess($account, $field_definition);
+    if (!$access_result->isAllowed()) {
+      $reason = 'The current user is not permitted to upload a file for this field.';
+      if ($access_result instanceof AccessResultReasonInterface) {
+        $reason .= ' ' . $access_result->getReason();
+      }
+      throw new AccessDeniedHttpException($reason);
+    }
+  }
+
+  /**
+   * Validates and loads a field definition instance.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID the field is attached to.
+   * @param string $bundle
+   *   The bundle the field is attached to.
+   * @param string $field_name
+   *   The field name.
+   *
+   * @return \Drupal\Core\Field\FieldDefinitionInterface
+   *   The field definition.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   *   Thrown when the field does not exist.
+   * @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException
+   *   Thrown when the target type of the field is not a file, or the current
+   *   user does not have 'edit' access for the field.
+   */
+  protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) {
+    $field_definitions = $this->fieldManager->getFieldDefinitions($entity_type_id, $bundle);
+    if (!isset($field_definitions[$field_name])) {
+      throw new NotFoundHttpException(sprintf('Field "%s" does not exist.', $field_name));
+    }
+
+    /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
+    $field_definition = $field_definitions[$field_name];
+    if ($field_definition->getSetting('target_type') !== 'file') {
+      throw new AccessDeniedException(sprintf('"%s" is not a file field', $field_name));
+    }
+
+    return $field_definition;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php b/core/modules/jsonapi/src/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php
new file mode 100644
index 0000000000..eeee4ad4a9
--- /dev/null
+++ b/core/modules/jsonapi/src/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\jsonapi\DependencyInjection\Compiler;
+
+use Drupal\serialization\RegisterSerializationClassesCompilerPass as DrupalRegisterSerializationClassesCompilerPass;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Adds services tagged JSON:API-only normalizers to the Serializer.
+ *
+ * Services tagged with 'jsonapi_normalizer' will be added to the JSON:API
+ * serializer. No extensions can provide such services.
+ *
+ * JSON:API does respect generic (non-JSON:API) DataType-level normalizers.
+ *
+ * @see jsonapi.api.php
+ *
+ * @internal
+ */
+class RegisterSerializationClassesCompilerPass extends DrupalRegisterSerializationClassesCompilerPass {
+
+  /**
+   * The service ID.
+   *
+   * @const string
+   */
+  const OVERRIDDEN_SERVICE_ID = 'jsonapi.serializer';
+
+  /**
+   * The service tag that only JSON:API normalizers should use.
+   *
+   * @const string
+   */
+  const OVERRIDDEN_SERVICE_NORMALIZER_TAG = 'jsonapi_normalizer';
+
+  /**
+   * The service tag that only JSON:API encoders should use.
+   *
+   * @const string
+   */
+  const OVERRIDDEN_SERVICE_ENCODER_TAG = 'jsonapi_encoder';
+
+  /**
+   * The ID for the JSON:API format.
+   *
+   * @const string
+   */
+  const FORMAT = 'api_json';
+
+  /**
+   * Adds services to the JSON:API Serializer.
+   *
+   * This code is copied from the class parent with two modifications. The
+   * service id has been changed and the service tag has been updated.
+   *
+   * ID: 'serializer' -> 'jsonapi.serializer'
+   * Tag: 'normalizer' -> 'jsonapi_normalizer'
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+   *   The container to process.
+   */
+  public function process(ContainerBuilder $container) {
+    $definition = $container->getDefinition(static::OVERRIDDEN_SERVICE_ID);
+
+    // Retrieve registered Normalizers and Encoders from the container.
+    foreach ($container->findTaggedServiceIds(static::OVERRIDDEN_SERVICE_NORMALIZER_TAG) as $id => $attributes) {
+      // Normalizers are not an API: mark private.
+      $container->getDefinition($id)->setPublic(FALSE);
+
+      $priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
+      $normalizers[$priority][] = new Reference($id);
+    }
+    foreach ($container->findTaggedServiceIds(static::OVERRIDDEN_SERVICE_ENCODER_TAG) as $id => $attributes) {
+      // Encoders are not an API: mark private.
+      $container->getDefinition($id)->setPublic(FALSE);
+
+      $priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
+      $encoders[$priority][] = new Reference($id);
+    }
+
+    // Add the registered Normalizers and Encoders to the Serializer.
+    if (!empty($normalizers)) {
+      $definition->replaceArgument(0, $this->sort($normalizers));
+    }
+    if (!empty($encoders)) {
+      $definition->replaceArgument(1, $this->sort($encoders));
+    }
+
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Encoder/JsonEncoder.php b/core/modules/jsonapi/src/Encoder/JsonEncoder.php
new file mode 100644
index 0000000000..08bda5fc27
--- /dev/null
+++ b/core/modules/jsonapi/src/Encoder/JsonEncoder.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\jsonapi\Encoder;
+
+use Drupal\serialization\Encoder\JsonEncoder as SerializationJsonEncoder;
+
+/**
+ * Encodes JSON:API data.
+ *
+ * @internal
+ */
+class JsonEncoder extends SerializationJsonEncoder {
+
+  /**
+   * The formats that this Encoder supports.
+   *
+   * @var string
+   */
+  protected static $format = ['api_json'];
+
+}
diff --git a/core/modules/jsonapi/src/Entity/EntityValidationTrait.php b/core/modules/jsonapi/src/Entity/EntityValidationTrait.php
new file mode 100644
index 0000000000..65e123c9a5
--- /dev/null
+++ b/core/modules/jsonapi/src/Entity/EntityValidationTrait.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\jsonapi\Entity;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+
+/**
+ * Provides a method to validate an entity.
+ *
+ * @internal
+ */
+trait EntityValidationTrait {
+
+  /**
+   * Verifies that an entity does not violate any validation constraints.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object.
+   * @param string[] $field_names
+   *   (optional) An array of field names. If specified, filters the violations
+   *   list to include only this set of fields. Defaults to NULL,
+   *   which means that all violations will be reported.
+   *
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when violations remain after filtering.
+   *
+   * @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
+   */
+  protected static function validate(EntityInterface $entity, array $field_names = NULL) {
+    if (!$entity instanceof FieldableEntityInterface) {
+      return;
+    }
+
+    $violations = $entity->validate();
+
+    // Remove violations of inaccessible fields as they cannot stem from our
+    // changes.
+    $violations->filterByFieldAccess();
+
+    // Filter violations based on the given fields.
+    if ($field_names !== NULL) {
+      $violations->filterByFields(
+        array_diff(array_keys($entity->getFieldDefinitions()), $field_names)
+      );
+    }
+
+    if (count($violations) > 0) {
+      // Instead of returning a generic 400 response we use the more specific
+      // 422 Unprocessable Entity code from RFC 4918. That way clients can
+      // distinguish between general syntax errors in bad serializations (code
+      // 400) and semantic errors in well-formed requests (code 422).
+      // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
+      $exception = new UnprocessableHttpEntityException();
+      $exception->setViolations($violations);
+      throw $exception;
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
new file mode 100644
index 0000000000..80ddbe4e36
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\Routing\Routes;
+use Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber as SerializationDefaultExceptionSubscriber;
+use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Serializes exceptions in compliance with the  JSON:API specification.
+ *
+ * @internal
+ */
+class DefaultExceptionSubscriber extends SerializationDefaultExceptionSubscriber {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getPriority() {
+    return parent::getPriority() + 25;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getHandledFormats() {
+    return ['api_json'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onException(GetResponseForExceptionEvent $event) {
+    if (!$this->isJsonApiExceptionEvent($event)) {
+      return;
+    }
+    if (($exception = $event->getException()) && !$exception instanceof HttpException) {
+      $exception = new HttpException(500, $exception->getMessage(), $exception);
+      $event->setException($exception);
+    }
+
+    $this->setEventResponse($event, $exception->getStatusCode());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setEventResponse(GetResponseForExceptionEvent $event, $status) {
+    /* @var \Symfony\Component\HttpKernel\Exception\HttpException $exception */
+    $exception = $event->getException();
+    $response = new ResourceResponse(new JsonApiDocumentTopLevel(new ErrorCollection([$exception]), new NullEntityCollection(), new LinkCollection([])), $exception->getStatusCode(), $exception->getHeaders());
+    $response->addCacheableDependency($exception);
+    $event->setResponse($response);
+  }
+
+  /**
+   * Check if the error should be formatted using JSON:API.
+   *
+   * The JSON:API format is supported if the format is explicitly set or the
+   * request is for a known JSON:API route.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $exception_event
+   *   The exception event.
+   *
+   * @return bool
+   *   TRUE if it needs to be formatted using JSON:API. FALSE otherwise.
+   */
+  protected function isJsonApiExceptionEvent(GetResponseForExceptionEvent $exception_event) {
+    $request = $exception_event->getRequest();
+    $parameters = $request->attributes->all();
+    return $request->getRequestFormat() === 'api_json' || (bool) Routes::getResourceTypeNameFromParameters($parameters);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php b/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php
new file mode 100644
index 0000000000..0f802f7044
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/JsonApiRequestValidator.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\JsonApiSpec;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Event\GetResponseEvent;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * Request subscriber that validates a JSON:API request.
+ *
+ * @internal
+ */
+class JsonApiRequestValidator implements EventSubscriberInterface {
+
+  /**
+   * Validates JSON:API requests.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
+   *   The event to process.
+   */
+  public function onRequest(GetResponseEvent $event) {
+    $request = $event->getRequest();
+    if ($request->getRequestFormat() !== 'api_json') {
+      return;
+    }
+
+    $this->validateQueryParams($request);
+  }
+
+  /**
+   * Validates custom (implementation-specific) query parameter names.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request for which to validate JSON:API query parameters.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse|null
+   *   A JSON:API resource response.
+   *
+   * @see http://jsonapi.org/format/#query-parameters
+   */
+  protected function validateQueryParams(Request $request) {
+    $invalid_query_params = [];
+    foreach (array_keys($request->query->all()) as $query_parameter_name) {
+      // Ignore reserved (official) query parameters.
+      if (in_array($query_parameter_name, JsonApiSpec::getReservedQueryParameters())) {
+        continue;
+      }
+
+      if (!JsonApiSpec::isValidCustomQueryParameter($query_parameter_name)) {
+        $invalid_query_params[] = $query_parameter_name;
+      }
+    }
+
+    // Drupal uses the `_format` query parameter for Content-Type negotiation.
+    // Using it violates the JSON:API spec. Nudge people nicely in the correct
+    // direction. (This is special cased because using it is pretty common.)
+    if (in_array('_format', $invalid_query_params, TRUE)) {
+      $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
+      $exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args:_format']), 'JSON:API does not need that ugly \'_format\' query string! 🤘 Use the URL provided in \'links\' 🙏');
+      $exception->setHeaders(['Link' => $uri_without_query_string]);
+      throw $exception;
+    }
+
+    if (empty($invalid_query_params)) {
+      return NULL;
+    }
+
+    $message = sprintf('The following query parameters violate the JSON:API spec: \'%s\'.', implode("', '", $invalid_query_params));
+    $exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args']), $message);
+    $exception->setHeaders(['Link' => 'http://jsonapi.org/format/#query-parameters']);
+    throw $exception;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[KernelEvents::REQUEST][] = ['onRequest'];
+    return $events;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php
new file mode 100644
index 0000000000..af7a125305
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php
@@ -0,0 +1,179 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Core\Cache\CacheableResponse;
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceResponse;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Response subscriber that serializes and removes ResourceResponses' data.
+ *
+ * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
+ * @internal
+ *
+ * This is 99% identical to:
+ *
+ * \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
+ *
+ * but with a few differences:
+ * 1. It has the @jsonapi.serializer service injected instead of @serializer
+ * 2. It has the @current_route_match service no longer injected
+ * 3. It hardcodes the format to 'api_json'
+ * 4. It adds the CacheableNormalization object returned by JSON:API
+ *    normalization to the response object.
+ * 5. It flattens only to a cacheable response if the HTTP method is cacheable.
+ */
+class ResourceResponseSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\SerializerInterface
+   */
+  protected $serializer;
+
+  /**
+   * Constructs a ResourceResponseSubscriber object.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer.
+   */
+  public function __construct(SerializerInterface $serializer) {
+    $this->serializer = $serializer;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::getSubscribedEvents()
+   * @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber
+   */
+  public static function getSubscribedEvents() {
+    // Run before the dynamic page cache subscriber (priority 100), so that
+    // Dynamic Page Cache can cache flattened responses.
+    $events[KernelEvents::RESPONSE][] = ['onResponse', 128];
+    return $events;
+  }
+
+  /**
+   * Serializes ResourceResponse responses' data, and removes that data.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onResponse(FilterResponseEvent $event) {
+    $response = $event->getResponse();
+    if (!$response instanceof ResourceResponse) {
+      return;
+    }
+
+    $request = $event->getRequest();
+    $format = 'api_json';
+    $this->renderResponseBody($request, $response, $this->serializer, $format);
+    $event->setResponse($this->flattenResponse($response, $request));
+  }
+
+  /**
+   * Renders a resource response body.
+   *
+   * Serialization can invoke rendering (e.g., generating URLs), but the
+   * serialization API does not provide a mechanism to collect the
+   * bubbleable metadata associated with that (e.g., language and other
+   * contexts), so instead, allow those to "leak" and collect them here in
+   * a render context.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\jsonapi\ResourceResponse $response
+   *   The response from the JSON:API resource.
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer to use.
+   * @param string|null $format
+   *   The response format, or NULL in case the response does not need a format,
+   *   for example for the response to a DELETE request.
+   *
+   * @todo Add test coverage for language negotiation contexts in
+   *   https://www.drupal.org/node/2135829.
+   */
+  protected function renderResponseBody(Request $request, ResourceResponse $response, SerializerInterface $serializer, $format) {
+    $data = $response->getResponseData();
+
+    // If there is data to send, serialize and set it as the response body.
+    if ($data !== NULL) {
+      // First normalize the data. Note that error responses do not need a
+      // normalization context, since there are no entities to normalize.
+      // @see \Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber::isJsonApiExceptionEvent()
+      $context = !$response->isSuccessful() ? [] : static::generateContext($request);
+      $jsonapi_doc_object = $serializer->normalize($data, $format, $context);
+      // Having just normalized the data, we can associate its cacheability with
+      // the response object.
+      assert($jsonapi_doc_object instanceof CacheableNormalization);
+      $response->addCacheableDependency($jsonapi_doc_object);
+      // Finally, encode the normalized data (JSON:API's encoder rasterizes it
+      // automatically).
+      $response->setContent($serializer->encode($jsonapi_doc_object->getNormalization(), $format));
+      $response->headers->set('Content-Type', $request->getMimeType($format));
+    }
+  }
+
+  /**
+   * Generates a top-level JSON:API normalization context.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request from which the context can be derived.
+   *
+   * @return array
+   *   The generated context.
+   */
+  protected static function generateContext(Request $request) {
+    // Build the expanded context.
+    $context = [
+      'account' => NULL,
+      'sparse_fieldset' => NULL,
+    ];
+    if ($request->query->get('fields')) {
+      $context['sparse_fieldset'] = array_map(function ($item) {
+        return explode(',', $item);
+      }, $request->query->get('fields'));
+    }
+    return $context;
+  }
+
+  /**
+   * Flattens a fully rendered resource response.
+   *
+   * Ensures that complex data structures in ResourceResponse::getResponseData()
+   * are not serialized. Not doing this means that caching this response object
+   * requires deserializing the PHP data when reading this response object from
+   * cache, which can be very costly, and is unnecessary.
+   *
+   * @param \Drupal\jsonapi\ResourceResponse $response
+   *   A fully rendered resource response.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request for which this response is generated.
+   *
+   * @return \Drupal\Core\Cache\CacheableResponse|\Symfony\Component\HttpFoundation\Response
+   *   The flattened response.
+   */
+  protected static function flattenResponse(ResourceResponse $response, Request $request) {
+    $final_response = ($response instanceof CacheableResponseInterface && $request->isMethodCacheable()) ? new CacheableResponse() : new Response();
+    $final_response->setContent($response->getContent());
+    $final_response->setStatusCode($response->getStatusCode());
+    $final_response->setProtocolVersion($response->getProtocolVersion());
+    $final_response->setCharset($response->getCharset());
+    $final_response->headers = clone $response->headers;
+    if ($final_response instanceof CacheableResponseInterface) {
+      $final_response->addCacheableDependency($response->getCacheableMetadata());
+    }
+    return $final_response;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/ResourceResponseValidator.php b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseValidator.php
new file mode 100644
index 0000000000..d19154e6c5
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseValidator.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\jsonapi\ResourceResponse;
+use JsonSchema\Validator;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Response subscriber that validates a JSON:API response.
+ *
+ * This must run after ResourceResponseSubscriber.
+ *
+ * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
+ * @internal
+ */
+class ResourceResponseValidator implements EventSubscriberInterface {
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\SerializerInterface
+   */
+  protected $serializer;
+
+  /**
+   * The JSON:API logger channel.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The schema validator.
+   *
+   * This property will only be set if the validator library is available.
+   *
+   * @var \JsonSchema\Validator|null
+   */
+  protected $validator;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The application's root file path.
+   *
+   * @var string
+   */
+  protected $appRoot;
+
+  /**
+   * Constructs a ResourceResponseValidator object.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The JSON:API logger channel.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param string $app_root
+   *   The application's root file path.
+   */
+  public function __construct(SerializerInterface $serializer, LoggerInterface $logger, ModuleHandlerInterface $module_handler, $app_root) {
+    $this->serializer = $serializer;
+    $this->logger = $logger;
+    $this->moduleHandler = $module_handler;
+    $this->appRoot = $app_root;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[KernelEvents::RESPONSE][] = ['onResponse'];
+    return $events;
+  }
+
+  /**
+   * Sets the validator service if available.
+   */
+  public function setValidator(Validator $validator = NULL) {
+    if ($validator) {
+      $this->validator = $validator;
+    }
+    elseif (class_exists(Validator::class)) {
+      $this->validator = new Validator();
+    }
+  }
+
+  /**
+   * Validates JSON:API responses.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onResponse(FilterResponseEvent $event) {
+    $response = $event->getResponse();
+    if (!$response instanceof ResourceResponse) {
+      return;
+    }
+
+    $this->doValidateResponse($response, $event->getRequest());
+  }
+
+  /**
+   * Wraps validation in an assert to prevent execution in production.
+   *
+   * @see self::validateResponse
+   */
+  public function doValidateResponse(Response $response, Request $request) {
+    if (PHP_MAJOR_VERSION >= 7 || assert_options(ASSERT_ACTIVE)) {
+      assert($this->validateResponse($response, $request), 'A JSON:API response failed validation (see the logs for details). Please report this in the issue queue on drupal.org');
+    }
+  }
+
+  /**
+   * Validates a response against the JSON:API specification.
+   *
+   * @param \Symfony\Component\HttpFoundation\Response $response
+   *   The response to validate.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request containing info about what to validate.
+   *
+   * @return bool
+   *   FALSE if the response failed validation, otherwise TRUE.
+   */
+  protected function validateResponse(Response $response, Request $request) {
+    // If the validator isn't set, then the validation library is not installed.
+    if (!$this->validator) {
+      return TRUE;
+    }
+
+    // Do not use Json::decode here since it coerces the response into an
+    // associative array, which creates validation errors.
+    $response_data = json_decode($response->getContent());
+    if (empty($response_data)) {
+      return TRUE;
+    }
+
+    $schema_ref = sprintf(
+      'file://%s/schema.json',
+      implode('/', [
+        $this->appRoot,
+        $this->moduleHandler->getModule('jsonapi')->getPath(),
+      ])
+    );
+    $generic_jsonapi_schema = (object) ['$ref' => $schema_ref];
+
+    return $this->validateSchema($generic_jsonapi_schema, $response_data);
+  }
+
+  /**
+   * Validates a string against a JSON Schema. It logs any possible errors.
+   *
+   * @param object $schema
+   *   The JSON Schema object.
+   * @param string $response_data
+   *   The JSON string to validate.
+   *
+   * @return bool
+   *   TRUE if the string is a valid instance of the schema. FALSE otherwise.
+   */
+  protected function validateSchema($schema, $response_data) {
+    $this->validator->check($response_data, $schema);
+    $is_valid = $this->validator->isValid();
+    if (!$is_valid) {
+      $this->logger->debug("Response failed validation.\nResponse:\n@data\n\nErrors:\n@errors", [
+        '@data' => Json::encode($response_data),
+        '@errors' => Json::encode($this->validator->getErrors()),
+      ]);
+    }
+    return $is_valid;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Exception/EntityAccessDeniedHttpException.php b/core/modules/jsonapi/src/Exception/EntityAccessDeniedHttpException.php
new file mode 100644
index 0000000000..fc2cfe37fe
--- /dev/null
+++ b/core/modules/jsonapi/src/Exception/EntityAccessDeniedHttpException.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\jsonapi\Exception;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierTrait;
+
+/**
+ * Enhances the access denied exception with information about the entity.
+ *
+ * @internal
+ */
+class EntityAccessDeniedHttpException extends CacheableAccessDeniedHttpException implements ResourceIdentifierInterface {
+
+  use DependencySerializationTrait;
+  use ResourceIdentifierTrait;
+
+  /**
+   * The error which caused the 403.
+   *
+   * The error contains:
+   *   - entity: The entity which the current user doens't have access to.
+   *   - pointer: A path in the JSON:API response structure pointing to the
+   *     entity.
+   *   - reason: (Optional) An optional reason for this failure.
+   *
+   * @var array
+   */
+  protected $error = [];
+
+  /**
+   * EntityAccessDeniedHttpException constructor.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   The entity, or NULL when an entity is being created.
+   * @param \Drupal\Core\Access\AccessResultInterface $entity_access
+   *   The access result.
+   * @param string $pointer
+   *   (optional) The pointer.
+   * @param string $message
+   *   (Optional) The display to display.
+   * @param string $relationship_field
+   *   (Optional) A relationship field name if access was denied because the
+   *   user does not have permission to view an entity's relationship field.
+   * @param \Exception|null $previous
+   *   The previous exception.
+   * @param int $code
+   *   The code.
+   */
+  public function __construct($entity, AccessResultInterface $entity_access, $pointer, $message = 'The current user is not allowed to GET the selected resource.', $relationship_field = NULL, \Exception $previous = NULL, $code = 0) {
+    assert(is_null($entity) || $entity instanceof EntityInterface);
+    parent::__construct(CacheableMetadata::createFromObject($entity_access), $message, $previous, $code);
+    $error = [
+      'entity' => $entity,
+      'pointer' => $pointer,
+      'reason' => NULL,
+      'relationship_field' => $relationship_field,
+    ];
+    if ($entity_access instanceof AccessResultReasonInterface) {
+      $error['reason'] = $entity_access->getReason();
+    }
+    $this->error = $error;
+    // @todo: remove this ternary operation in https://www.drupal.org/project/jsonapi/issues/2997594.
+    $this->resourceIdentifier = $entity ? ResourceIdentifier::fromEntity($entity) : NULL;
+  }
+
+  /**
+   * Returns the error.
+   *
+   * @return array
+   *   The error.
+   */
+  public function getError() {
+    return $this->error;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php b/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php
new file mode 100644
index 0000000000..495f974930
--- /dev/null
+++ b/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\jsonapi\Exception;
+
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * A class to represent a 422 - Unprocessable Entity Exception.
+ *
+ * The HTTP 422 status code is used when the server sees:-
+ *
+ *  The content type of the request is correct.
+ *  The syntax of the request is correct.
+ *  BUT was unable to process the contained instruction.
+ *
+ * @internal
+ */
+class UnprocessableHttpEntityException extends HttpException {
+
+  use DependencySerializationTrait;
+
+  /**
+   * The constraint violations associated with this exception.
+   *
+   * @var \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   */
+  protected $violations;
+
+  /**
+   * UnprocessableHttpEntityException constructor.
+   *
+   * @param \Exception|null $previous
+   *   The pervious error, if any, associated with the request.
+   * @param array $headers
+   *   The headers associated with the request.
+   * @param int $code
+   *   The HTTP status code associated with the request. Defaults to zero.
+   */
+  public function __construct(\Exception $previous = NULL, array $headers = [], $code = 0) {
+    parent::__construct(422, "Unprocessable Entity: validation failed.", $previous, $headers, $code);
+  }
+
+  /**
+   * Gets the constraint violations associated with this exception.
+   *
+   * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   *   The constraint violations.
+   */
+  public function getViolations() {
+    return $this->violations;
+  }
+
+  /**
+   * Sets the constraint violations associated with this exception.
+   *
+   * @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations
+   *   The constraint violations.
+   */
+  public function setViolations(EntityConstraintViolationListInterface $violations) {
+    $this->violations = $violations;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ForwardCompatibility/FileFieldUploader.php b/core/modules/jsonapi/src/ForwardCompatibility/FileFieldUploader.php
new file mode 100644
index 0000000000..970bdcfc18
--- /dev/null
+++ b/core/modules/jsonapi/src/ForwardCompatibility/FileFieldUploader.php
@@ -0,0 +1,479 @@
+<?php
+
+namespace Drupal\jsonapi\ForwardCompatibility;
+
+use Drupal\Component\Utility\Bytes;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Validation\DrupalTranslator;
+use Drupal\file\FileInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Utility\Token;
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\file\Entity\File;
+use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Reads data from an upload stream and creates a corresponding file entity.
+ *
+ * This is implemented at the field level for the following reasons:
+ *   - Validation for uploaded files is tied to fields (allowed extensions, max
+ *     size, etc..).
+ *   - The actual files do not need to be stored in another temporary location,
+ *     to be later moved when they are referenced from a file field.
+ *   - Permission to upload a file can be determined by a user's field- and
+ *     entity-level access.
+ *
+ * @internal
+ */
+class FileFieldUploader {
+
+  /**
+   * The regex used to extract the filename from the content disposition header.
+   *
+   * @var string
+   */
+  const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
+
+  /**
+   * The amount of bytes to read in each iteration when streaming file data.
+   *
+   * @var int
+   */
+  const BYTES_TO_READ = 8192;
+
+  /**
+   * A logger instance.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The MIME type guesser.
+   *
+   * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
+   */
+  protected $mimeTypeGuesser;
+
+  /**
+   * The token replacement instance.
+   *
+   * @var \Drupal\Core\Utility\Token
+   */
+  protected $token;
+
+  /**
+   * The lock service.
+   *
+   * @var \Drupal\Core\Lock\LockBackendInterface
+   */
+  protected $lock;
+
+  /**
+   * System file configuration.
+   *
+   * @var \Drupal\Core\Config\ImmutableConfig
+   */
+  protected $systemFileConfig;
+
+  /**
+   * Constructs a FileUploadResource instance.
+   *
+   * @param \Psr\Log\LoggerInterface $logger
+   *   A logger instance.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser
+   *   The MIME type guesser.
+   * @param \Drupal\Core\Utility\Token $token
+   *   The token replacement instance.
+   * @param \Drupal\Core\Lock\LockBackendInterface $lock
+   *   The lock service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   */
+  public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory) {
+    $this->logger = $logger;
+    $this->fileSystem = $file_system;
+    $this->mimeTypeGuesser = $mime_type_guesser;
+    $this->token = $token;
+    $this->lock = $lock;
+    $this->systemFileConfig = $config_factory->get('system.file');
+  }
+
+  /**
+   * Creates and validates a file entity for a file field from a file stream.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition of the field for which the file is to be uploaded.
+   * @param string $filename
+   *   The name of the file.
+   * @param resource $stream
+   *   The upload stream.
+   * @param \Drupal\Core\Session\AccountInterface $owner
+   *   The owner of the file. Note, it is the responsibility of the caller to
+   *   enforce access.
+   *
+   * @return \Drupal\file\FileInterface|\Drupal\Core\Entity\EntityConstraintViolationListInterface
+   *   The newly uploaded file entity, or a list of validation constraint
+   *   violations
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   Thrown when temporary files cannot be written, a lock cannot be acquired,
+   *   or when temporary files cannot be moved to their new location.
+   */
+  public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, $stream, AccountInterface $owner) {
+    assert(is_a($field_definition->getClass(), FileFieldItemList::class, TRUE));
+    $destination = $this->getUploadLocation($field_definition->getSettings());
+
+    // Check the destination file path is writable.
+    if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
+      throw new HttpException(500, 'Destination file path is not writable');
+    }
+
+    $validators = $this->getUploadValidators($field_definition);
+
+    $prepared_filename = $this->prepareFilename($filename, $validators);
+
+    // Create the file.
+    $file_uri = "{$destination}/{$prepared_filename}";
+
+    $temp_file_path = $this->readStreamDataToFile($stream);
+
+    // This will take care of altering $file_uri if a file already exists.
+    file_unmanaged_prepare($temp_file_path, $file_uri);
+
+    // Lock based on the prepared file URI.
+    $lock_id = $this->generateLockIdFromFileUri($file_uri);
+
+    if (!$this->lock->acquire($lock_id)) {
+      throw new HttpException(503, sprintf('File "%s" is already locked for writing.'), NULL, ['Retry-After' => 1]);
+    }
+
+    // Begin building file entity.
+    $file = File::create([]);
+    $file->setOwnerId($owner->id());
+    $file->setFilename($prepared_filename);
+    $file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
+    $file->setFileUri($file_uri);
+    // Set the size. This is done in File::preSave() but we validate the file
+    // before it is saved.
+    $file->setSize(@filesize($temp_file_path));
+
+    // Validate the file entity against entity-level validation and field-level
+    // validators.
+    $violations = $this->validate($file, $validators);
+    if ($violations->count() > 0) {
+      return $violations;
+    }
+
+    // Move the file to the correct location after validation. Use
+    // FILE_EXISTS_ERROR as the file location has already been determined above
+    // in file_unmanaged_prepare().
+    if (!file_unmanaged_move($temp_file_path, $file_uri, FILE_EXISTS_ERROR)) {
+      throw new HttpException(500, 'Temporary file could not be moved to file location.');
+    }
+
+    $file->save();
+
+    $this->lock->release($lock_id);
+
+    return $file;
+  }
+
+  /**
+   * Validates and extracts the filename from the Content-Disposition header.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return string
+   *   The filename extracted from the header.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when the 'Content-Disposition' request header is invalid.
+   */
+  public static function validateAndParseContentDispositionHeader(Request $request) {
+    // First, check the header exists.
+    if (!$request->headers->has('content-disposition')) {
+      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
+    }
+
+    $content_disposition = $request->headers->get('content-disposition');
+
+    // Parse the header value. This regex does not allow an empty filename.
+    // i.e. 'filename=""'. This also matches on a word boundary so other keys
+    // like 'not_a_filename' don't work.
+    if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
+      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
+    }
+
+    // Check for the "filename*" format. This is currently unsupported.
+    if (!empty($matches['star'])) {
+      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
+    }
+
+    // Don't validate the actual filename here, that will be done by the upload
+    // validators in validate().
+    // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
+    $filename = $matches['filename'];
+
+    // Make sure only the filename component is returned. Path information is
+    // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
+    return basename($filename);
+  }
+
+  /**
+   * Gets a standard file upload stream.
+   *
+   * @return resource
+   *   A stream ready to be read. Do not forget to close the stream after use
+   *   with fclose().
+   */
+  public static function getUploadStream() {
+    return fopen('php://input', 'rb');
+  }
+
+  /**
+   * Checks if the current user has access to upload the file.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which file upload access should be checked.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition for which to get validators.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   (optional) The entity to which the file is to be uploaded, if it exists.
+   *   If the entity does not exist and it is not given, create access to the
+   *   file will be checked.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The file upload access result.
+   */
+  public static function checkFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, EntityInterface $entity = NULL) {
+    assert(is_null($entity) || $field_definition->getTargetEntityTypeId() === $entity->getEntityTypeId() && $field_definition->getTargetBundle() === $entity->bundle());
+    $entity_type_manager = \Drupal::entityTypeManager();
+    $entity_access_control_handler = $entity_type_manager->getAccessControlHandler($field_definition->getTargetEntityTypeId());
+    $bundle = $entity_type_manager->getDefinition($field_definition->getTargetEntityTypeId())->hasKey('bundle') ? $field_definition->getTargetBundle() : NULL;
+    $entity_access_result = $entity
+      ? $entity_access_control_handler->access($entity, 'update', $account, TRUE)
+      : $entity_access_control_handler->createAccess($bundle, $account, [], TRUE);
+    $field_access_result = $entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE);
+    return $entity_access_result->andIf($field_access_result);
+  }
+
+  /**
+   * Reads file upload data to temporary file and moves to file destination.
+   *
+   * Note that the given stream will not be closed by this method.
+   *
+   * @param resource $stream
+   *   The stream on the file.
+   *
+   * @return string
+   *   The temp file path.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   Thrown when input data cannot be read, the temporary file cannot be
+   *   opened, or the temporary file cannot be written.
+   */
+  protected function readStreamDataToFile($stream) {
+    $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file');
+    $temp_file = fopen($temp_file_path, 'wb');
+
+    if ($temp_file) {
+      while (!feof($stream)) {
+        $read = fread($stream, static::BYTES_TO_READ);
+
+        if ($read === FALSE) {
+          // Close the file streams.
+          fclose($temp_file);
+          $this->logger->error('Input data could not be read');
+          throw new HttpException(500, 'Input file data could not be read.');
+        }
+
+        if (fwrite($temp_file, $read) === FALSE) {
+          // Close the file streams.
+          fclose($temp_file);
+          $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
+          throw new HttpException(500, 'Temporary file data could not be written.');
+        }
+      }
+
+      // Close the temp file stream.
+      fclose($temp_file);
+    }
+    else {
+      // Close the file streams.
+      fclose($temp_file);
+      $this->logger->error('Temporary file "%path" could not be opened for file upload.', ['%path' => $temp_file_path]);
+      throw new HttpException(500, 'Temporary file could not be opened');
+    }
+
+    return $temp_file_path;
+  }
+
+  /**
+   * Validates the file.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *   The file entity to validate.
+   * @param array $validators
+   *   An array of upload validators to pass to file_validate().
+   *
+   * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   *   The list of constraint violations, if any.
+   */
+  protected function validate(FileInterface $file, array $validators) {
+    $violations = $file->validate();
+
+    // Remove violations of inaccessible fields as they cannot stem from our
+    // changes.
+    $violations->filterByFieldAccess();
+
+    // Validate the file based on the field definition configuration.
+    $errors = file_validate($file, $validators);
+    if (!empty($errors)) {
+      $translator = new DrupalTranslator();
+      foreach ($errors as $error) {
+        $violation = new ConstraintViolation($translator->trans($error),
+          $error,
+          [],
+          EntityAdapter::createFromEntity($file),
+          '',
+          NULL
+        );
+        $violations->add($violation);
+      }
+    }
+
+    return $violations;
+  }
+
+  /**
+   * Prepares the filename to strip out any malicious extensions.
+   *
+   * @param string $filename
+   *   The file name.
+   * @param array $validators
+   *   The array of upload validators.
+   *
+   * @return string
+   *   The prepared/munged filename.
+   */
+  protected function prepareFilename($filename, array &$validators) {
+    if (!empty($validators['file_validate_extensions'][0])) {
+      // If there is a file_validate_extensions validator and a list of
+      // valid extensions, munge the filename to protect against possible
+      // malicious extension hiding within an unknown file type. For example,
+      // "filename.html.foo".
+      $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
+    }
+
+    // Rename potentially executable files, to help prevent exploits (i.e. will
+    // rename filename.php.foo and filename.php to filename.php.foo.txt and
+    // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
+    // evaluates to TRUE.
+    if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {
+      // The destination filename will also later be used to create the URI.
+      $filename .= '.txt';
+
+      // The .txt extension may not be in the allowed list of extensions. We
+      // have to add it here or else the file upload will fail.
+      if (!empty($validators['file_validate_extensions'][0])) {
+        $validators['file_validate_extensions'][0] .= ' txt';
+      }
+    }
+
+    return $filename;
+  }
+
+  /**
+   * Determines the URI for a file field.
+   *
+   * @param array $settings
+   *   The array of field settings.
+   *
+   * @return string
+   *   An un-sanitized file directory URI with tokens replaced. The result of
+   *   the token replacement is then converted to plain text and returned.
+   */
+  protected function getUploadLocation(array $settings) {
+    $destination = trim($settings['file_directory'], '/');
+
+    // Replace tokens. As the tokens might contain HTML we convert it to plain
+    // text.
+    $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata()));
+    return $settings['uri_scheme'] . '://' . $destination;
+  }
+
+  /**
+   * Retrieves the upload validators for a field definition.
+   *
+   * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
+   * is no entity instance available here that that a FileItem would exist for.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition for which to get validators.
+   *
+   * @return array
+   *   An array suitable for passing to file_save_upload() or the file field
+   *   element's '#upload_validators' property.
+   */
+  protected function getUploadValidators(FieldDefinitionInterface $field_definition) {
+    $validators = [
+      // Add in our check of the file name length.
+      'file_validate_name_length' => [],
+    ];
+    $settings = $field_definition->getSettings();
+
+    // Cap the upload size according to the PHP limit.
+    $max_filesize = Bytes::toInt(file_upload_max_size());
+    if (!empty($settings['max_filesize'])) {
+      $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
+    }
+
+    // There is always a file size limit due to the PHP server limit.
+    $validators['file_validate_size'] = [$max_filesize];
+
+    // Add the extension check if necessary.
+    if (!empty($settings['file_extensions'])) {
+      $validators['file_validate_extensions'] = [$settings['file_extensions']];
+    }
+
+    return $validators;
+  }
+
+  /**
+   * Generates a lock ID based on the file URI.
+   *
+   * @param string $file_uri
+   *   The file URI.
+   *
+   * @return string
+   *   The generated lock ID.
+   */
+  protected static function generateLockIdFromFileUri($file_uri) {
+    return 'file:rest:' . Crypt::hashBase64($file_uri);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeIso8601Normalizer.php b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeIso8601Normalizer.php
new file mode 100644
index 0000000000..7091a2a955
--- /dev/null
+++ b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeIso8601Normalizer.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\jsonapi\ForwardCompatibility\Normalizer;
+
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\TypedData\Plugin\DataType\DateTimeIso8601;
+use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem;
+use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
+
+/**
+ * Converts values for the DateTimeIso8601 data type to RFC3339.
+ *
+ * @internal
+ * @see \Drupal\serialization\Normalizer\DateTimeIso8601Normalizer
+ * @todo Remove when JSON:API requires a version of Drupal core that includes https://www.drupal.org/project/drupal/issues/2926508.
+ */
+class DateTimeIso8601Normalizer extends DateTimeNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $allowedFormats = [
+    'RFC 3339' => \DateTime::RFC3339,
+    'ISO 8601' => \DateTime::ISO8601,
+    // @todo Remove this in https://www.drupal.org/project/drupal/issues/2958416.
+    // RFC3339 only covers combined date and time representations. For date-only
+    // representations, we need to use ISO 8601. There isn't a constant on the
+    // \DateTime class that we can use, so we have to hardcode the format.
+    // @see https://en.wikipedia.org/wiki/ISO_8601#Calendar_dates
+    // @see \Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface::DATE_STORAGE_FORMAT
+    'date-only' => 'Y-m-d',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = DateTimeIso8601::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($datetime, $format = NULL, array $context = []) {
+    $field_item = $datetime->getParent();
+    // @todo Remove this in https://www.drupal.org/project/drupal/issues/2958416.
+    if ($field_item instanceof DateTimeItem && $field_item->getFieldDefinition()->getFieldStorageDefinition()->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) {
+      // @todo Remove when JSON:API only supports Drupal >=8.7, which fixed this in https://www.drupal.org/project/drupal/issues/3002164.
+      $drupal_date_time = floatval(floatval(\Drupal::VERSION) >= 8.7)
+        ? $datetime->getDateTime()
+        : ($datetime->getValue() ? new DrupalDateTime($datetime->getValue(), 'UTC') : NULL);
+      if ($drupal_date_time === NULL) {
+        return $drupal_date_time;
+      }
+      return $drupal_date_time->format($this->allowedFormats['date-only']);
+    }
+    return parent::normalize($datetime, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    // @todo Move the date-only handling out of here in https://www.drupal.org/project/drupal/issues/2958416.
+    $field_definition = isset($context['target_instance'])
+      ? $context['target_instance']->getFieldDefinition()
+      : (isset($context['field_definition']) ? $context['field_definition'] : NULL);
+    $datetime_type = $field_definition->getSetting('datetime_type');
+    $is_date_only = $datetime_type === DateTimeItem::DATETIME_TYPE_DATE;
+
+    if ($is_date_only) {
+      $context['datetime_allowed_formats'] = array_intersect_key($this->allowedFormats, ['date-only' => TRUE]);
+      $datetime = parent::denormalize($data, $class, $format, $context);
+      unset($context['datetime_allowed_formats']);
+      if (!$datetime instanceof \DateTime) {
+        return $datetime;
+      }
+      return $datetime->format(DateTimeItemInterface::DATE_STORAGE_FORMAT);
+    }
+    else {
+      $context['datetime_allowed_formats'] = array_diff_key($this->allowedFormats, ['date-only' => TRUE]);
+      try {
+        $datetime = parent::denormalize($data, $class, $format, $context);
+      }
+      catch (\UnexpectedValueException $e) {
+        // If denormalization didn't work using any of the actively supported
+        // formats, try again with the BC format too. Explicitly label it as
+        // being deprecated and trigger a deprecation error.
+        $using_deprecated_format = TRUE;
+        $context['datetime_allowed_formats']['backward compatibility — deprecated'] = DateTimeItemInterface::DATETIME_STORAGE_FORMAT;
+        $datetime = parent::denormalize($data, $class, $format, $context);
+      }
+      unset($context['datetime_allowed_formats']);
+      if (!$datetime instanceof \DateTime) {
+        return $datetime;
+      }
+      if (isset($using_deprecated_format)) {
+        @trigger_error('The provided datetime string format (Y-m-d\\TH:i:s) is deprecated and will be removed before Drupal 9.0.0. Use the RFC3339 format instead (Y-m-d\\TH:i:sP).', E_USER_DEPRECATED);
+      }
+      $datetime->setTimezone(new \DateTimeZone(DateTimeItemInterface::STORAGE_TIMEZONE));
+      return $datetime->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT);
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeNormalizer.php b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeNormalizer.php
new file mode 100644
index 0000000000..53f01e03d1
--- /dev/null
+++ b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeNormalizer.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\jsonapi\ForwardCompatibility\Normalizer;
+
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\TypedData\Type\DateTimeInterface;
+use Drupal\jsonapi\Normalizer\NormalizerBase;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts values for datetime objects to RFC3339 and from common formats.
+ *
+ * @internal
+ * @see \Drupal\serialization\Normalizer\DateTimeNormalizer
+ * @todo Remove when JSON:API requires a version of Drupal core that includes https://www.drupal.org/project/drupal/issues/2926508.
+ */
+class DateTimeNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * Allowed datetime formats for the denormalizer.
+   *
+   * The list is chosen to be unambiguous and language neutral, but also common
+   * for data interchange.
+   *
+   * @var string[]
+   *
+   * @see http://php.net/manual/en/datetime.createfromformat.php
+   */
+  protected $allowedFormats = [
+    'RFC 3339' => \DateTime::RFC3339,
+    'ISO 8601' => \DateTime::ISO8601,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = DateTimeInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($datetime, $format = NULL, array $context = []) {
+    // @todo Remove when JSON:API only supports Drupal >=8.7, which fixed this in https://www.drupal.org/project/drupal/issues/3002164.
+    $drupal_date_time = floatval(floatval(\Drupal::VERSION) >= 8.7)
+      ? $datetime->getDateTime()
+      : ($datetime->getValue() ? new DrupalDateTime($datetime->getValue(), 'UTC') : NULL);
+    if ($drupal_date_time === NULL) {
+      return $drupal_date_time;
+    }
+    return $drupal_date_time
+      // Set an explicit timezone. Otherwise, timestamps may end up being
+      // normalized using the user's preferred timezone. Which would result in
+      // many variations and complex caching.
+      // @see \Drupal\Core\Datetime\DrupalDateTime::prepareTimezone()
+      // @see drupal_get_user_timezone()
+      ->setTimezone($this->getNormalizationTimezone())
+      ->format(\DateTime::RFC3339);
+  }
+
+  /**
+   * Gets the timezone to be used during normalization.
+   *
+   * @see ::normalize
+   *
+   * @returns \DateTimeZone
+   *   The timezone to use.
+   */
+  protected function getNormalizationTimezone() {
+    $default_site_timezone = \Drupal::config('system.date')->get('timezone.default');
+    return new \DateTimeZone($default_site_timezone);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    // This only knows how to denormalize datetime strings and timestamps. If
+    // something else is received, let validation constraints handle this.
+    if (!is_string($data) && !is_numeric($data)) {
+      return $data;
+    }
+
+    // Loop through the allowed formats and create a \DateTime from the
+    // input data if it matches the defined pattern. Since the formats are
+    // unambiguous (i.e., they reference an absolute time with a defined time
+    // zone), only one will ever match.
+    $allowed_formats = isset($context['datetime_allowed_formats'])
+      ? $context['datetime_allowed_formats']
+      : $this->allowedFormats;
+    foreach ($allowed_formats as $format) {
+      $date = \DateTime::createFromFormat($format, $data);
+      $errors = \DateTime::getLastErrors();
+      if ($date !== FALSE && empty($errors['errors']) && empty($errors['warnings'])) {
+        return $date;
+      }
+    }
+
+    $format_strings = [];
+
+    foreach ($allowed_formats as $label => $format) {
+      $format_strings[] = "\"$format\" ($label)";
+    }
+
+    $formats = implode(', ', $format_strings);
+    throw new UnexpectedValueException(sprintf('The specified date "%s" is not in an accepted format: %s.', $data, $formats));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/TimestampNormalizer.php b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/TimestampNormalizer.php
new file mode 100644
index 0000000000..9894e6374c
--- /dev/null
+++ b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/TimestampNormalizer.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\jsonapi\ForwardCompatibility\Normalizer;
+
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\TypedData\Plugin\DataType\Timestamp;
+
+/**
+ * Converts values for the Timestamp data type to and from common formats.
+ *
+ * @internal
+ * @see \Drupal\serialization\Normalizer\TimestampNormalizer
+ * @todo Remove when JSON:API requires a version of Drupal core that includes https://www.drupal.org/project/drupal/issues/2926508.
+ */
+class TimestampNormalizer extends DateTimeNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $allowedFormats = [
+    'UNIX timestamp' => 'U',
+    'ISO 8601' => \DateTime::ISO8601,
+    'RFC 3339' => \DateTime::RFC3339,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = Timestamp::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($datetime, $format = NULL, array $context = []) {
+    return DrupalDateTime::createFromTimestamp($datetime->getValue())
+      ->setTimezone($this->getNormalizationTimezone())
+      ->format(\DateTime::RFC3339);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizationTimezone() {
+    return new \DateTimeZone('UTC');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $denormalized = parent::denormalize($data, $class, $format, $context);
+    return $denormalized->getTimestamp();
+  }
+
+}
diff --git a/core/modules/jsonapi/src/IncludeResolver.php b/core/modules/jsonapi/src/IncludeResolver.php
new file mode 100644
index 0000000000..e3e03b7393
--- /dev/null
+++ b/core/modules/jsonapi/src/IncludeResolver.php
@@ -0,0 +1,246 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\jsonapi\Access\EntityAccessChecker;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Resolves included resources for an entity or collection of entities.
+ *
+ * @internal
+ */
+class IncludeResolver {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The JSON:API entity access checker.
+   *
+   * @var \Drupal\jsonapi\Access\EntityAccessChecker
+   */
+  protected $entityAccessChecker;
+
+  /**
+   * IncludeResolver constructor.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityAccessChecker $entity_access_checker) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityAccessChecker = $entity_access_checker;
+  }
+
+  /**
+   * Resolves included resources.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
+   *   The resource(s) for which to resolve includes.
+   * @param string $include_parameter
+   *   The include query parameter to resolve.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   An EntityCollection of resolved resources to be included.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   *   Thrown if an included entity type doesn't exist.
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   *   Thrown if a storage handler couldn't be loaded.
+   */
+  public function resolve($data, $include_parameter) {
+    assert($data instanceof ResourceIdentifierInterface || $data instanceof EntityCollection);
+    // Map a single entity into an EntityCollection.
+    $entity_collection = $data instanceof ResourceIdentifierInterface ? new EntityCollection([$data], 1) : $data;
+    $include_tree = static::toIncludeTree($entity_collection, $include_parameter);
+    return EntityCollection::deduplicate($this->resolveIncludeTree($include_tree, $entity_collection));
+  }
+
+  /**
+   * Receives a tree of include field names and resolves resources for it.
+   *
+   * This method takes a tree of relationship field names and an
+   * EntityCollection object. For the top-level of the tree and for each entity
+   * in the collection, it gets the target entity type and IDs for each
+   * relationship field. The method then loads all of those targets and calls
+   * itself recursively with the next level of the tree and those loaded
+   * resources.
+   *
+   * @param array $include_tree
+   *   The include paths, represented as a tree.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entity_collection
+   *   The entity collection from which includes should be resolved.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection|null $includes
+   *   (Internal use only) Any prior resolved includes.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   An EntityCollection of included items.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   *   Thrown if an included entity type doesn't exist.
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   *   Thrown if a storage handler couldn't be loaded.
+   */
+  protected function resolveIncludeTree(array $include_tree, EntityCollection $entity_collection, EntityCollection $includes = NULL) {
+    $includes = is_null($includes) ? new EntityCollection([]) : $includes;
+    foreach ($include_tree as $field_name => $children) {
+      $references = [];
+      foreach ($entity_collection as $resource_object) {
+        // Some objects in the collection may be LabelOnlyResourceObjects or
+        // EntityAccessDeniedHttpException objects.
+        assert($resource_object instanceof ResourceIdentifierInterface);
+        if ($resource_object instanceof LabelOnlyResourceObject) {
+          $message = "The current user is not allowed to view this relationship.";
+          $exception = new EntityAccessDeniedHttpException($resource_object->getEntity(), AccessResult::forbidden("The user only has authorization for the 'view label' operation."), '', $message, $field_name);
+          $includes = EntityCollection::merge($includes, new EntityCollection([$exception]));
+          continue;
+        }
+        elseif (!$resource_object instanceof ResourceObject) {
+          continue;
+        }
+        $public_field_name = $resource_object->getResourceType()->getPublicName($field_name);
+        // Not all entities in $entity_collection will be of the same bundle and
+        // may not have all of the same fields. Therefore, calling
+        // $resource_object->get($a_missing_field_name) will result in an
+        // exception.
+        if (!$resource_object->hasField($public_field_name)) {
+          continue;
+        }
+        $field_list = $resource_object->getField($public_field_name);
+        // Config entities don't have real fields and can't have relationships.
+        if (!$field_list instanceof FieldItemListInterface) {
+          continue;
+        }
+        $field_access = $field_list->access('view', NULL, TRUE);
+        if (!$field_access->isAllowed()) {
+          $message = 'The current user is not allowed to view this relationship.';
+          $exception = new EntityAccessDeniedHttpException($field_list->getEntity(), $field_access, '', $message, $public_field_name);
+          $includes = EntityCollection::merge($includes, new EntityCollection([$exception]));
+          continue;
+        }
+        $target_type = $field_list->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
+        assert(!empty($target_type));
+        foreach ($field_list as $field_item) {
+          assert($field_item instanceof EntityReferenceItem);
+          $references[$target_type][] = $field_item->get($field_item::mainPropertyName())->getValue();
+        }
+      }
+      foreach ($references as $target_type => $ids) {
+        $entity_storage = $this->entityTypeManager->getStorage($target_type);
+        $targeted_entities = $entity_storage->loadMultiple(array_unique($ids));
+        $access_checked_entities = array_map(function (EntityInterface $entity) {
+          return $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
+        }, $targeted_entities);
+        $targeted_collection = new EntityCollection(array_filter($access_checked_entities, function (ResourceIdentifierInterface $resource_object) {
+          return !$resource_object->getResourceType()->isInternal();
+        }));
+        $includes = static::resolveIncludeTree($children, $targeted_collection, EntityCollection::merge($includes, $targeted_collection));
+      }
+    }
+    return $includes;
+  }
+
+  /**
+   * Returns a tree of field names to include from an include parameter.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entity_collection
+   *   The base resources for which includes should be resolved.
+   * @param string $include_parameter
+   *   The raw include parameter value.
+   *
+   * @return array
+   *   An multi-dimensional array representing a tree of field names to be
+   *   included. Array keys are the field names. Leaves are empty arrays.
+   */
+  protected static function toIncludeTree(EntityCollection $entity_collection, $include_parameter) {
+    // $include_parameter: 'one.two.three, one.two.four'.
+    $include_paths = array_map('trim', explode(',', $include_parameter));
+    // $exploded_paths: [['one', 'two', 'three'], ['one', 'two', 'four']].
+    $exploded_paths = array_map(function ($include_path) {
+      return array_map('trim', explode('.', $include_path));
+    }, $include_paths);
+    $resolved_paths = [];
+    /* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface $item */
+    foreach ($entity_collection as $item) {
+      $resolved_paths = array_merge($resolved_paths, static::resolveInternalIncludePaths($item->getResourceType(), $exploded_paths));
+    }
+    return static::buildTree($resolved_paths);
+  }
+
+  /**
+   * Resolves an array of public field paths.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $base_resource_type
+   *   The base resource type from which to resolve an internal include path.
+   * @param array $paths
+   *   An array of exploded include paths.
+   *
+   * @return array
+   *   An array of all possible internal include paths derived from the given
+   *   public include paths.
+   *
+   * @see self::buildTree
+   */
+  protected static function resolveInternalIncludePaths(ResourceType $base_resource_type, array $paths) {
+    $internal_paths = array_map(function ($exploded_path) use ($base_resource_type) {
+      if (empty($exploded_path)) {
+        return [];
+      }
+      return FieldResolver::resolveInternalIncludePath($base_resource_type, $exploded_path);
+    }, $paths);
+    $flattened_paths = array_reduce($internal_paths, 'array_merge', []);
+    return $flattened_paths;
+  }
+
+  /**
+   * Takes an array of exploded paths and builds a tree of field names.
+   *
+   * Input example: [
+   *   ['one', 'two', 'three'],
+   *   ['one', 'two', 'four'],
+   *   ['one', 'two', 'internal'],
+   * ]
+   *
+   * Output example: [
+   *   'one' => [
+   *     'two' [
+   *       'three' => [],
+   *       'four' => [],
+   *       'internal' => [],
+   *     ],
+   *   ],
+   * ]
+   *
+   * @param array $paths
+   *   An array of exploded include paths.
+   *
+   * @return array
+   *   An multi-dimensional array representing a tree of field names to be
+   *   included. Array keys are the field names. Leaves are empty arrays.
+   */
+  protected static function buildTree(array $paths) {
+    $merged = [];
+    foreach ($paths as $parts) {
+      if (!$field_name = array_shift($parts)) {
+        continue;
+      }
+      $previous = isset($merged[$field_name]) ? $merged[$field_name] : [];
+      $merged[$field_name] = array_merge($previous, [$parts]);
+    }
+    return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/EntityCollection.php b/core/modules/jsonapi/src/JsonApiResource/EntityCollection.php
new file mode 100644
index 0000000000..63c804b2a4
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/EntityCollection.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+
+/**
+ * Wrapper to normalize collections with multiple entities.
+ *
+ * @internal
+ */
+class EntityCollection implements \IteratorAggregate, \Countable {
+
+  /**
+   * Various representations of entities.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]
+   */
+  protected $resourceObjects;
+
+  /**
+   * The number of resources permitted in this collection.
+   *
+   * @var int
+   */
+  protected $cardinality;
+
+  /**
+   * Holds a boolean indicating if there is a next page.
+   *
+   * @var bool
+   */
+  protected $hasNextPage;
+
+  /**
+   * Holds the total count of entities.
+   *
+   * @var int
+   */
+  protected $count;
+
+  /**
+   * Instantiates a EntityCollection object.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[] $resources
+   *   The resources for the collection.
+   * @param int $cardinality
+   *   The number of resources that this collection may contain. Related
+   *   resource collections may handle both to-one or to-many relationships. A
+   *   to-one relationship should have a cardinality of 1. Use -1 for unlimited
+   *   cardinality.
+   */
+  public function __construct(array $resources, $cardinality = -1) {
+    assert(Inspector::assertAllObjects($resources, ResourceIdentifierInterface::class));
+    assert($cardinality >= -1 && $cardinality !== 0, 'Cardinality must be -1 for unlimited cardinality or a positive integer.');
+    assert($cardinality === -1 || count($resources) <= $cardinality, 'If cardinality is not unlimited, the number of given resources must not exceed the cardinality of the collection.');
+    $this->resourceObjects = array_values($resources);
+    $this->cardinality = $cardinality;
+  }
+
+  /**
+   * Returns an iterator for entities.
+   *
+   * @return \ArrayIterator
+   *   An \ArrayIterator instance
+   */
+  public function getIterator() {
+    return new \ArrayIterator($this->resourceObjects);
+  }
+
+  /**
+   * Returns the number of entities.
+   *
+   * @return int
+   *   The number of parameters
+   */
+  public function count() {
+    return count($this->resourceObjects);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTotalCount() {
+    return $this->count;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setTotalCount($count) {
+    $this->count = $count;
+  }
+
+  /**
+   * Returns the collection as an array.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface[]
+   *   The array of entities.
+   */
+  public function toArray() {
+    return $this->resourceObjects;
+  }
+
+  /**
+   * Checks if there is a next page in the collection.
+   *
+   * @return bool
+   *   TRUE if the collection has a next page.
+   */
+  public function hasNextPage() {
+    return (bool) $this->hasNextPage;
+  }
+
+  /**
+   * Sets the has next page flag.
+   *
+   * Once the collection query has been executed and we build the entity
+   * collection, we now if there will be a next page with extra entities.
+   *
+   * @param bool $has_next_page
+   *   TRUE if the collection has a next page.
+   */
+  public function setHasNextPage($has_next_page) {
+    $this->hasNextPage = (bool) $has_next_page;
+  }
+
+  /**
+   * Gets the cardinality of this collection.
+   *
+   * @return int
+   *   The cardinality of the resource collection. -1 for unlimited cardinality.
+   */
+  public function getCardinality() {
+    return $this->cardinality;
+  }
+
+  /**
+   * Returns a new EntityCollection containing the entities of $this and $other.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $a
+   *   An EntityCollection object to be merged.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $b
+   *   An EntityCollection object to be merged.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   A new merged EntityCollection object.
+   */
+  public static function merge(EntityCollection $a, EntityCollection $b) {
+    return new static(array_merge($a->toArray(), $b->toArray()));
+  }
+
+  /**
+   * Returns a new, deduplicated EntityCollection.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $collection
+   *   The EntityCollection to deduplicate.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   A new merged EntityCollection object.
+   */
+  public static function deduplicate(EntityCollection $collection) {
+    $deduplicated = [];
+    foreach ($collection as $resource) {
+      $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
+      if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
+        $dedupe_key .= ':' . $error['relationship_field'];
+      }
+      $deduplicated[$dedupe_key] = $resource;
+    }
+    return new static(array_values($deduplicated));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ErrorCollection.php b/core/modules/jsonapi/src/JsonApiResource/ErrorCollection.php
new file mode 100644
index 0000000000..954a5ef218
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ErrorCollection.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
+
+/**
+ * To be used when the primary data is `errors`.
+ *
+ * @internal
+ *
+ * (The spec says the top-level `data` and `errors` members MUST NOT coexist.)
+ * @see http://jsonapi.org/format/#document-top-level
+ *
+ * @see http://jsonapi.org/format/#error-objects
+ */
+class ErrorCollection implements \IteratorAggregate {
+
+  /**
+   * The HTTP exceptions.
+   *
+   * @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface[]
+   */
+  protected $errors;
+
+  /**
+   * Instantiates an ErrorCollection object.
+   *
+   * @param \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface[] $errors
+   *   The errors.
+   */
+  public function __construct(array $errors) {
+    assert(Inspector::assertAll(function ($error) {
+      return $error instanceof HttpExceptionInterface;
+    }, $errors));
+    $this->errors = $errors;
+  }
+
+  /**
+   * Returns an iterator for errors.
+   *
+   * @return \ArrayIterator
+   *   An \ArrayIterator instance
+   */
+  public function getIterator() {
+    return new \ArrayIterator($this->errors);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php b/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
new file mode 100644
index 0000000000..12e07d4419
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+
+/**
+ * Represents a JSON:API document's "top level".
+ *
+ * @see http://jsonapi.org/format/#document-top-level
+ *
+ * @internal
+ *
+ * @todo Add support for the missing optional 'jsonapi' member or document why not.
+ */
+class JsonApiDocumentTopLevel {
+
+  /**
+   * The data to normalize.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\JsonApiResource\ErrorCollection|\Drupal\Core\Field\EntityReferenceFieldItemListInterface
+   */
+  protected $data;
+
+  /**
+   * The metadata to normalize.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * The links.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\LinkCollection
+   */
+  protected $links;
+
+  /**
+   * The includes to normalize.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\EntityCollection
+   */
+  protected $includes;
+
+  /**
+   * Instantiates a JsonApiDocumentTopLevel object.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\JsonApiResource\ErrorCollection|\Drupal\Core\Field\EntityReferenceFieldItemListInterface $data
+   *   The data to normalize. It can be either a ResourceObject, or a stand-in
+   *   for one, or a collection of the same.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $includes
+   *   An EntityCollection object containing resources to be included in the
+   *   response document or NULL if there should not be includes.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   A collection of links to resources related to the top-level document.
+   * @param array $meta
+   *   (optional) The metadata to normalize.
+   */
+  public function __construct($data, EntityCollection $includes, LinkCollection $links, array $meta = []) {
+    assert($data instanceof ResourceIdentifierInterface || $data instanceof EntityCollection || $data instanceof ErrorCollection || $data instanceof EntityReferenceFieldItemListInterface);
+    assert(!$data instanceof ErrorCollection || $includes instanceof NullEntityCollection);
+    $this->data = $data;
+    $this->includes = $includes;
+    $this->links = $links->withContext($this);
+    $this->meta = $meta;
+  }
+
+  /**
+   * Gets the data.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   *   The data.
+   */
+  public function getData() {
+    return $this->data;
+  }
+
+  /**
+   * Gets the links.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   The top-level links.
+   */
+  public function getLinks() {
+    return $this->links;
+  }
+
+  /**
+   * Gets the metadata.
+   *
+   * @return array
+   *   The metadata.
+   */
+  public function getMeta() {
+    return $this->meta;
+  }
+
+  /**
+   * Gets an EntityCollection of resources to be included in the response.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\EntityCollection
+   *   The includes.
+   */
+  public function getIncludes() {
+    return $this->includes;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php
new file mode 100644
index 0000000000..a67e698cb9
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Value object decorating a ResourceObject; only its label is available.
+ *
+ * @internal
+ */
+final class LabelOnlyResourceObject extends ResourceObject {
+
+  /**
+   * Gets the decorated entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The label for which to only normalize its label.
+   */
+  public function getEntity() {
+    return $this->entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function extractFields(EntityInterface $entity) {
+    $fields = parent::extractFields($entity);
+    $public_label_field_name = $this->resourceType->getPublicName($this->getLabelFieldName());
+    return array_intersect_key($fields, [$public_label_field_name => TRUE]);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/Link.php b/core/modules/jsonapi/src/JsonApiResource/Link.php
new file mode 100644
index 0000000000..704633bcd3
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/Link.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Url;
+
+/**
+ * Represents an RFC8288 based link.
+ *
+ * @see https://tools.ietf.org/html/rfc8288
+ *
+ * @internal
+ */
+final class Link implements CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+
+  /**
+   * The link URI.
+   *
+   * @var \Drupal\Core\Url
+   */
+  protected $uri;
+
+  /**
+   * The URI, as a string.
+   *
+   * @var string
+   */
+  protected $href;
+
+  /**
+   * The link relation types.
+   *
+   * @var string[]
+   */
+  protected $rel;
+
+  /**
+   * The link target attributes.
+   *
+   * @var string[]
+   *   An associative array where the keys are the attribute keys and values are
+   *   either string or an array of strings.
+   */
+  protected $attributes;
+
+  /**
+   * JSON:API Link constructor.
+   *
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   Any cacheability metadata associated with the link. For example, a
+   *   'call-to-action' link might reference a registration resource if an event
+   *   has vacancies or a wait-list resource otherwise. Therefore, the link's
+   *   cacheability might be depend on a certain entity's values other than the
+   *   entity on which the link will appear.
+   * @param \Drupal\Core\Url $url
+   *   The Url object for the link.
+   * @param string[] $link_relation_types
+   *   An array of registered or extension RFC8288 link relation types.
+   * @param array $target_attributes
+   *   An associative array of target attributes for the link.
+   *
+   * @see https://tools.ietf.org/html/rfc8288#section-2.1
+   */
+  public function __construct(CacheableMetadata $cacheability, Url $url, array $link_relation_types, array $target_attributes = []) {
+    // @todo: uncomment the extra assertion below when JSON:API begins to use its own extension relation types.
+    assert(/* !empty($link_relation_types) && */Inspector::assertAllStrings($link_relation_types));
+    assert(Inspector::assertAllStrings(array_keys($target_attributes)));
+    assert(Inspector::assertAll(function ($target_attribute_value) {
+      return is_string($target_attribute_value)
+        || is_array($target_attribute_value)
+        && Inspector::assertAllStrings($target_attribute_value);
+    }, array_values($target_attributes)));
+    $generated_url = $url->setAbsolute()->toString(TRUE);
+    $this->href = $generated_url->getGeneratedUrl();
+    $this->uri = $url;
+    $this->rel = $link_relation_types;
+    $this->attributes = $target_attributes;
+    $this->setCacheability($cacheability->addCacheableDependency($generated_url));
+  }
+
+  /**
+   * Gets the link's URI.
+   *
+   * @return \Drupal\Core\Url
+   *   The link's URI as a Url object.
+   */
+  public function getUri() {
+    return $this->uri;
+  }
+
+  /**
+   * Gets the link's URI as a string.
+   *
+   * @return string
+   *   The link's URI as a string.
+   */
+  public function getHref() {
+    return $this->href;
+  }
+
+  /**
+   * Gets the link's relation types.
+   *
+   * @return string[]
+   *   The link's relation types.
+   */
+  public function getLinkRelationTypes() {
+    return $this->rel;
+  }
+
+  /**
+   * Gets the link's target attributes.
+   *
+   * @return string[]
+   *   The link's target attributes.
+   */
+  public function getTargetAttributes() {
+    return $this->attributes;
+  }
+
+  /**
+   * Compares two links by their href.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\Link $a
+   *   The first link.
+   * @param \Drupal\jsonapi\JsonApiResource\Link $b
+   *   The second link.
+   *
+   * @return int
+   *   The result of strcmp() on the links' hrefs.
+   */
+  public static function compare(Link $a, Link $b) {
+    return strcmp($a->getHref(), $b->getHref());
+  }
+
+  /**
+   * Merges two link objects' relation types and target attributes.
+   *
+   * The links must share the same URI.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\Link $a
+   *   The first link.
+   * @param \Drupal\jsonapi\JsonApiResource\Link $b
+   *   The second link.
+   *
+   * @return static
+   *   A new JSON:API Link object with the link relation type and target
+   *   attributes merged.
+   */
+  public static function merge(Link $a, Link $b) {
+    assert(static::compare($a, $b) === 0);
+    $merged_rels = array_unique(array_merge($a->getLinkRelationTypes(), $b->getLinkRelationTypes()));
+    $merged_attributes = $a->getTargetAttributes();
+    foreach ($b->getTargetAttributes() as $key => $value) {
+      if (isset($merged_attributes[$key])) {
+        // The attribute values can be either a string or an array of strings.
+        $value = array_unique(array_merge(
+          is_string($merged_attributes[$key]) ? [$merged_attributes[$key]] : $merged_attributes[$key],
+          is_string($value) ? [$value] : $value
+        ));
+      }
+      $merged_attributes[$key] = count($value) === 1 ? reset($value) : $value;
+    }
+    $merged_cacheability = (new CacheableMetadata())->addCacheableDependency($a)->addCacheableDependency($b);
+    return new static($merged_cacheability, $a->getUri(), $merged_rels, $merged_attributes);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php b/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php
new file mode 100644
index 0000000000..25288fdcb3
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/LinkCollection.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+
+/**
+ * Contains a set of JSON:API Link objects.
+ *
+ * @internal
+ */
+final class LinkCollection implements \IteratorAggregate {
+
+  /**
+   * The links in the collection, keyed by unique strings.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\Link[]
+   */
+  protected $links;
+
+  /**
+   * The link context.
+   *
+   * All links objects exist within a context object. Links form a relationship
+   * between a source IRI and target IRI. A context is the link's source.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel
+   *
+   * @see https://tools.ietf.org/html/rfc8288#section-3.2
+   */
+  protected $context;
+
+  /**
+   * LinkCollection constructor.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\Link[] $links
+   *   An associated array of key names and JSON:API Link objects.
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   *   (internal use only) The context object. Use the self::withContext()
+   *   method to establish a context. This should be done automatically when
+   *   a LinkCollection is passed into a context object.
+   */
+  public function __construct(array $links, $context = NULL) {
+    assert(Inspector::assertAll(function ($key) {
+      return static::validKey($key);
+    }, array_keys($links)));
+    assert(Inspector::assertAll(function ($link) {
+      return $link instanceof Link || is_array($link) && Inspector::assertAllObjects($link, Link::class);
+    }, $links));
+    assert(is_null($context) || Inspector::assertAllObjects([$context], JsonApiDocumentTopLevel::class, ResourceObject::class));
+    ksort($links);
+    $this->links = array_map(function ($link) {
+      return is_array($link) ? $link : [$link];
+    }, $links);
+    $this->context = $context;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator() {
+    assert(!is_null($this->context), 'A LinkCollection is invalid unless a context has been established.');
+    return new \ArrayIterator($this->links);
+  }
+
+  /**
+   * Gets a new LinkCollection with the given link inserted.
+   *
+   * @param string $key
+   *   A key for the link. If the key already exists and the link shares an href
+   *   with an existing link with that key, those links will be merged together.
+   * @param \Drupal\jsonapi\JsonApiResource\Link $new_link
+   *   The link to insert.
+   *
+   * @return static
+   *   A new LinkCollection with the given link inserted or merged with the
+   *   current set of links.
+   */
+  public function withLink($key, Link $new_link) {
+    assert(static::validKey($key));
+    $merged = $this->links;
+    if (isset($merged[$key])) {
+      foreach ($merged[$key] as $index => $existing_link) {
+        if (Link::compare($existing_link, $new_link) === 0) {
+          $merged[$key][$index] = Link::merge($existing_link, $new_link);
+          return new static($merged, $this->context);
+        }
+      }
+    }
+    $merged[$key][] = $new_link;
+    return new static($merged, $this->context);
+  }
+
+  /**
+   * Whether a link with the given key exists.
+   *
+   * @param string $key
+   *   The key.
+   *
+   * @return bool
+   *   TRUE if a link with the given key exist, FALSE otherwise.
+   */
+  public function hasLinkWithKey($key) {
+    return array_key_exists($key, $this->links);
+  }
+
+  /**
+   * Establishes a new context for a LinkCollection.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject $context
+   *   The new context object.
+   *
+   * @return static
+   *   A new LinkCollection with the given context.
+   */
+  public function withContext($context) {
+    return new static($this->links, $context);
+  }
+
+  /**
+   * Gets the LinkCollection's context object.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject
+   *   The LinkCollection's context.
+   */
+  public function getContext() {
+    assert(!is_null($this->context), 'A LinkCollection is invalid unless a context has been established.');
+    return $this->context;
+  }
+
+  /**
+   * Filters a LinkCollection using the provided callback.
+   *
+   * @param callable $f
+   *   The filter callback. The callback has the signature below.
+   *
+   * @code
+   *   boolean callback(string $key, \Drupal\jsonapi\JsonApiResource\Link $link, mixed $context))
+   * @endcode
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   A new, filtered LinkCollection.
+   */
+  public function filter(callable $f) {
+    $links = iterator_to_array($this);
+    $filtered = array_reduce(array_keys($links), function ($filtered, $key) use ($links, $f) {
+      if ($f($key, $links[$key], $this->context)) {
+        $filtered[$key] = $links[$key];
+      }
+      return $filtered;
+    }, []);
+    return new LinkCollection($filtered, $this->context);
+  }
+
+  /**
+   * Merges two LinkCollections.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $a
+   *   The first link collection.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $b
+   *   The second link collection.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   A new LinkCollection with the links of both inputs.
+   */
+  public static function merge(LinkCollection $a, LinkCollection $b) {
+    assert($a->getContext() === $b->getContext());
+    $merged = new LinkCollection([], $a->getContext());
+    foreach ($a as $key => $links) {
+      $merged = array_reduce($links, function (self $merged, Link $link) use ($key) {
+        return $merged->withLink($key, $link);
+      }, $merged);
+    }
+    foreach ($b as $key => $links) {
+      $merged = array_reduce($links, function (self $merged, Link $link) use ($key) {
+        return $merged->withLink($key, $link);
+      }, $merged);
+    }
+    return $merged;
+  }
+
+  /**
+   * Ensures that a link key is valid.
+   *
+   * @param string $key
+   *   A key name.
+   *
+   * @return bool
+   *   TRUE if the key is valid, FALSE otherwise.
+   */
+  protected static function validKey($key) {
+    return is_string($key) && !is_numeric($key) && strpos($key, ':') === FALSE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/NullEntityCollection.php b/core/modules/jsonapi/src/JsonApiResource/NullEntityCollection.php
new file mode 100644
index 0000000000..750ce32cfb
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/NullEntityCollection.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+/**
+ * Use when there are no included resources but an EntityCollection is required.
+ *
+ * @internal
+ */
+class NullEntityCollection extends EntityCollection {
+
+  /**
+   * NullEntityCollection constructor.
+   */
+  public function __construct() {
+    parent::__construct([]);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
new file mode 100644
index 0000000000..78b7e51fef
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
@@ -0,0 +1,454 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Represents a JSON:API resource identifier object.
+ *
+ * The official JSON:API JSON-Schema document requires that no two resource
+ * identifier objects are duplicates, however Drupal allows multiple entity
+ * reference items to the same entity. Here, these are termed "parallel"
+ * relationships (as in "parallel edges" of a graph).
+ *
+ * This class adds a concept of an @code arity @endcode member under each its
+ * @code meta @endcode object. The value of this member is an integer that is
+ * incremented by 1 (starting from 0) for each repeated resource identifier
+ * sharing a common @code type @endcode and @code id @endcode.
+ *
+ * There are a number of helper methods to process the logic of dealing with
+ * resource identifies with and without arity.
+ *
+ * @see http://jsonapi.org/format/#document-resource-object-relationships
+ * @see https://github.com/json-api/json-api/pull/1156#issuecomment-325377995
+ * @see https://www.drupal.org/project/jsonapi/issues/2864680
+ *
+ * @internal
+ */
+class ResourceIdentifier implements ResourceIdentifierInterface {
+
+  const ARITY_KEY = 'arity';
+
+  /**
+   * The JSON:API resource type name.
+   *
+   * @var string
+   */
+  protected $resourceTypeName;
+
+  /**
+   * The JSON:API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The resource ID.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The relationship's metadata.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * ResourceIdentifier constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType|string $resource_type
+   *   The JSON:API resource type or a JSON:API resource type name.
+   * @param string $id
+   *   The resource ID.
+   * @param array $meta
+   *   Any metadata for the ResourceIdentifier.
+   */
+  public function __construct($resource_type, $id, array $meta = []) {
+    assert(is_string($resource_type) || $resource_type instanceof ResourceType);
+    assert(!isset($meta[static::ARITY_KEY]) || is_int($meta[static::ARITY_KEY]) && $meta[static::ARITY_KEY] >= 0);
+    $this->resourceTypeName = is_string($resource_type) ? $resource_type : $resource_type->getTypeName();
+    $this->id = $id;
+    $this->meta = $meta;
+    if (!is_string($resource_type)) {
+      $this->resourceType = $resource_type;
+    }
+  }
+
+  /**
+   * Gets the ResourceIdentifier's JSON:API resource type name.
+   *
+   * @return string
+   *   The JSON:API resource type name.
+   */
+  public function getTypeName() {
+    return $this->resourceTypeName;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResourceType() {
+    if (!isset($this->resourceType)) {
+      /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+      $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+      $this->resourceType = $resource_type_repository->getByTypeName($this->getTypeName());
+    }
+    return $this->resourceType;
+  }
+
+  /**
+   * Gets the ResourceIdentifier's ID.
+   *
+   * @return string
+   *   The ID.
+   */
+  public function getId() {
+    return $this->id;
+  }
+
+  /**
+   * Whether this ResourceIdentifier has an arity.
+   *
+   * @return int
+   *   TRUE if the ResourceIdentifier has an arity, FALSE otherwise.
+   */
+  public function hasArity() {
+    return isset($this->meta[static::ARITY_KEY]);
+  }
+
+  /**
+   * Gets the ResourceIdentifier's arity.
+   *
+   * One must check self::hasArity() before calling this method.
+   *
+   * @return int
+   *   The arity.
+   */
+  public function getArity() {
+    assert($this->hasArity());
+    return $this->meta[static::ARITY_KEY];
+  }
+
+  /**
+   * Returns a copy of the given ResourceIdentifier with the given arity.
+   *
+   * @param int $arity
+   *   The new arity; must be a non-negative integer.
+   *
+   * @return static
+   *   A newly created ResourceIdentifier with the given arity, otherwise
+   *   the same.
+   */
+  public function withArity($arity) {
+    return new static($this->getResourceType(), $this->getId(), [static::ARITY_KEY => $arity] + $this->getMeta());
+  }
+
+  /**
+   * Gets the resource identifier objects metadata.
+   *
+   * @return array
+   *   The metadata.
+   */
+  public function getMeta() {
+    return $this->meta;
+  }
+
+  /**
+   * Determines if two ResourceIdentifiers are the same.
+   *
+   * This method does not consider parallel relationships with different arity
+   * values to be duplicates. For that, use the isParallel() method.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
+   *   The first ResourceIdentifier object.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
+   *   The second ResourceIdentifier object.
+   *
+   * @return bool
+   *   TRUE if both relationships reference the same resource and do not have
+   *   two distinct arity's, FALSE otherwise.
+   *
+   *   For example, if $a and $b both reference the same resource identifier,
+   *   they can only be distinct if they *both* have an arity and those values
+   *   are not the same. If $a or $b does not have an arity, they will be
+   *   considered duplicates.
+   */
+  public static function isDuplicate(ResourceIdentifier $a, ResourceIdentifier $b) {
+    return static::compare($a, $b) === 0;
+  }
+
+  /**
+   * Determines if two ResourceIdentifiers identify the same resource object.
+   *
+   * This method does not consider arity.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
+   *   The first ResourceIdentifier object.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
+   *   The second ResourceIdentifier object.
+   *
+   * @return bool
+   *   TRUE if both relationships reference the same resource, even when they
+   *   have differing arity values, FALSE otherwise.
+   */
+  public static function isParallel(ResourceIdentifier $a, ResourceIdentifier $b) {
+    return static::compare($a->withArity(0), $b->withArity(0)) === 0;
+  }
+
+  /**
+   * Compares ResourceIdentifier objects.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
+   *   The first ResourceIdentifier object.
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
+   *   The second ResourceIdentifier object.
+   *
+   * @return int
+   *   Returns 0 if $a and $b are duplicate ResourceIdentifiers. If $a and $b
+   *   identify the same resource but have distinct arity values, then the
+   *   return value will be arity $a minus arity $b. -1 otherwise.
+   */
+  public static function compare(ResourceIdentifier $a, ResourceIdentifier $b) {
+    $result = strcmp(sprintf('%s:%s', $a->getTypeName(), $a->getId()), sprintf('%s:%s', $b->getTypeName(), $b->getId()));
+    // If type and ID do not match, return their ordering.
+    if ($result !== 0) {
+      return $result;
+    }
+    // If both $a and $b have an arity, then return the order by arity.
+    // Otherwise, they are considered equal.
+    return $a->hasArity() && $b->hasArity()
+      ? $a->getArity() - $b->getArity()
+      : 0;
+  }
+
+  /**
+   * Deduplicates an array of ResourceIdentifier objects.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The list of ResourceIdentifiers to deduplicate.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[]
+   *   A deduplicated array of ResourceIdentifier objects.
+   *
+   * @see self::isDuplicate()
+   */
+  public static function deduplicate(array $resource_identifiers) {
+    return array_reduce(array_slice($resource_identifiers, 1), function ($deduplicated, $current) {
+      assert($current instanceof static);
+      return array_merge($deduplicated, array_reduce($deduplicated, function ($duplicate, $previous) use ($current) {
+        return $duplicate ?: static::isDuplicate($previous, $current);
+      }, FALSE) ? [] : [$current]);
+    }, array_slice($resource_identifiers, 0, 1));
+  }
+
+  /**
+   * Determines if an array of ResourceIdentifier objects is duplicate free.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
+   *   The list of ResourceIdentifiers to assess.
+   *
+   * @return bool
+   *   Whether all the given resource identifiers are unique.
+   */
+  public static function areResourceIdentifiersUnique(array $resource_identifiers) {
+    return count($resource_identifiers) === count(static::deduplicate($resource_identifiers));
+  }
+
+  /**
+   * Creates a ResourceIdentifier object.
+   *
+   * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
+   *   The entity reference field item from which to create the relationship.
+   * @param int $arity
+   *   (optional) The arity of the relationship.
+   *
+   * @return self
+   *   A new ResourceIdentifier object.
+   */
+  public static function toResourceIdentifier(EntityReferenceItem $item, $arity = NULL) {
+    $property_name = static::getDataReferencePropertyName($item);
+    $target = $item->get($property_name)->getValue();
+    if ($target === NULL) {
+      return static::getVirtualOrMissingResourceIdentifier($item);
+    }
+    assert($target instanceof EntityInterface);
+    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+    $resource_type = $resource_type_repository->get($target->getEntityTypeId(), $target->bundle());
+    // Remove unwanted properties from the meta value, usually 'entity'
+    // and 'target_id'.
+    $properties = TypedDataInternalPropertiesHelper::getNonInternalProperties($item);
+    $meta = array_diff_key($properties, array_flip([$property_name, $item->getDataDefinition()->getMainPropertyName()]));
+    if (!is_null($arity)) {
+      $meta[static::ARITY_KEY] = $arity;
+    }
+    return new static($resource_type, $target->uuid(), $meta);
+  }
+
+  /**
+   * Creates an array of ResourceIdentifier objects.
+   *
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items
+   *   The entity reference field items from which to create the relationship
+   *   array.
+   *
+   * @return self[]
+   *   An array of new ResourceIdentifier objects with appropriate arity values.
+   */
+  public static function toResourceIdentifiers(EntityReferenceFieldItemListInterface $items) {
+    $relationships = [];
+    foreach ($items as $item) {
+      // Create a ResourceIdentifier from the field item. This will make it
+      // comparable with all previous field items. Here, it is assumed that the
+      // resource identifier is unique so it has no arity. If a parallel
+      // relationship is encountered, it will be assigned later.
+      $relationship = static::toResourceIdentifier($item);
+      // Now, iterate over the previously seen resource identifiers in reverse
+      // order. Reverse order is important so that when a parallel relationship
+      // is encountered, it will have the highest arity value so the current
+      // relationship's arity value can simply be incremented by one.
+      /* @var self $existing */
+      foreach (array_reverse($relationships, TRUE) as $index => $existing) {
+        $is_parallel = static::isParallel($existing, $relationship);
+        if ($is_parallel) {
+          // A parallel relationship has been found. If the previous
+          // relationship does not have an arity, it must now be assigned an
+          // arity of 0.
+          if (!$existing->hasArity()) {
+            $relationships[$index] = $existing->withArity(0);
+          }
+          // Since the new ResourceIdentifier is parallel, it must have an arity
+          // assigned to it that is the arity of the last parallel
+          // relationship's arity + 1.
+          $relationship = $relationship->withArity($relationships[$index]->getArity() + 1);
+          break;
+        }
+      }
+      // Finally, append the relationship to the list of ResourceIdentifiers.
+      $relationships[] = $relationship;
+    }
+    return $relationships;
+  }
+
+  /**
+   * Creates an array of ResourceIdentifier objects with arity on every value.
+   *
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items
+   *   The entity reference field items from which to create the relationship
+   *   array.
+   *
+   * @return self[]
+   *   An array of new ResourceIdentifier objects with appropriate arity values.
+   *   Unlike self::toResourceIdentifiers(), this method does not omit arity
+   *   when an identifier is not parallel to any other identifier.
+   */
+  public static function toResourceIdentifiersWithArityRequired(EntityReferenceFieldItemListInterface $items) {
+    return array_map(function (ResourceIdentifier $identifier) {
+      return $identifier->hasArity() ? $identifier : $identifier->withArity(0);
+    }, static::toResourceIdentifiers($items));
+  }
+
+  /**
+   * Creates a ResourceIdentifier object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity from which to create the resource identifier.
+   *
+   * @return self
+   *   A new ResourceIdentifier object.
+   */
+  public static function fromEntity(EntityInterface $entity) {
+    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+    $resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle());
+    return new static($resource_type, $entity->uuid());
+  }
+
+  /**
+   * Helper method to determine which field item property contains an entity.
+   *
+   * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
+   *   The entity reference item for which to determine the entity property
+   *   name.
+   *
+   * @return string
+   *   The property name which has an entity as its value.
+   */
+  protected static function getDataReferencePropertyName(EntityReferenceItem $item) {
+    foreach ($item->getDataDefinition()->getPropertyDefinitions() as $property_name => $property_definition) {
+      if ($property_definition instanceof DataReferenceDefinitionInterface) {
+        return $property_name;
+      }
+    }
+  }
+
+  /**
+   * Creates a ResourceIdentifier for a NULL or FALSE entity reference item.
+   *
+   * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
+   *   The entity reference field item.
+   *
+   * @return self
+   *   A new ResourceIdentifier object.
+   */
+  protected static function getVirtualOrMissingResourceIdentifier(EntityReferenceItem $item) {
+    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+    $property_name = static::getDataReferencePropertyName($item);
+    $value = $item->get($property_name)->getValue();
+    assert($value === NULL);
+    $field = $item->getParent();
+    assert($field instanceof EntityReferenceFieldItemListInterface);
+    $host_entity = $field->getEntity();
+    assert($host_entity instanceof EntityInterface);
+    $resource_type = $resource_type_repository->get($host_entity->getEntityTypeId(), $host_entity->bundle());
+    assert($resource_type instanceof ResourceType);
+    $relatable_resource_types = $resource_type->getRelatableResourceTypesByField($field->getName());
+    $get_metadata = function ($type) {
+      return [
+        'links' => [
+          'help' => [
+            'href' => "https://www.drupal.org/docs/8/modules/json-api/core-concepts#$type",
+            'meta' => [
+              'about' => "Usage and meaning of the '$type' resource identifier.",
+            ],
+          ],
+        ],
+      ];
+    };
+    $resource_type = reset($relatable_resource_types);
+    // A non-empty entity reference field that refers to a non-existent entity
+    // is not a data integrity problem. For example, Term entities' "parent"
+    // entity reference field uses target_id zero to refer to the non-existent
+    // "<root>" term. And references to entities that no longer exist are not
+    // cleaned up by Drupal; hence we map it to a "missing" resource.
+    if ($field->getFieldDefinition()->getSetting('target_type') === 'taxonomy_term' && $item->get('target_id')->getCastedValue() === 0) {
+      if (count($relatable_resource_types) !== 1) {
+        throw new \RuntimeException('Relationships to virtual resources are possible only if a single resource type is relatable.');
+      }
+      return new static($resource_type, 'virtual', $get_metadata('virtual'));
+    }
+    else {
+      // In case of a dangling reference, it is impossible to determine which
+      // resource type it used to reference, because that requires knowing the
+      // referenced bundle, which Drupal does not store.
+      // If we can reliably determine the resource type of the dangling
+      // reference, use it; otherwise conjure a fake resource type out of thin
+      // air, one that indicates we don't know the bundle.
+      $resource_type = count($relatable_resource_types) > 1
+        ? new ResourceType('?', '?', '')
+        : reset($relatable_resource_types);
+      return new static($resource_type, 'missing', $get_metadata('missing'));
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php
new file mode 100644
index 0000000000..a23e0c220c
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+/**
+ * An interface for identifying a related resource.
+ *
+ * Implement this interface when an object is a stand-in for an Entity object.
+ * For example, \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+ * implements this interface because it often replaces an entity in an
+ * EntityCollection.
+ *
+ * @internal
+ */
+interface ResourceIdentifierInterface {
+
+  /**
+   * Gets the resource identifier's ID.
+   *
+   * @return string
+   *   A resource ID.
+   */
+  public function getId();
+
+  /**
+   * Gets the resource identifier's JSON:API resource type name.
+   *
+   * @return string
+   *   The JSON:API resource type name.
+   */
+  public function getTypeName();
+
+  /**
+   * Gets the resource identifier's JSON:API resource type.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   The JSON:API resource type.
+   */
+  public function getResourceType();
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php
new file mode 100644
index 0000000000..407f2add4b
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+/**
+ * Used to associate an object like an exception to a particular resource.
+ *
+ * @internal
+ *
+ * @see \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface
+ */
+trait ResourceIdentifierTrait {
+
+  /**
+   * A ResourceIdentifier object.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifier
+   */
+  protected $resourceIdentifier;
+
+  /**
+   * The JSON:API resource type of of the identified resource object.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getId() {
+    return $this->resourceIdentifier->getId();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTypeName() {
+    return $this->resourceIdentifier->getTypeName();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResourceType() {
+    if (!isset($this->resourceType)) {
+      $this->resourceType = $this->resourceIdentifier->getResourceType();
+    }
+    return $this->resourceType;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php
new file mode 100644
index 0000000000..3082a8ff69
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\Core\Url;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Routing\Routes;
+
+/**
+ * Represents a JSON:API resource object.
+ *
+ * This value object wraps a Drupal entity so that it can carry a JSON:API
+ * resource type object alongside it. It also helps abstract away differences
+ * between config and content entities within the JSON:API codebase.
+ *
+ * @internal
+ */
+class ResourceObject implements CacheableDependencyInterface, ResourceIdentifierInterface {
+
+  use CacheableDependencyTrait;
+  use ResourceIdentifierTrait;
+
+  /**
+   * The object's fields.
+   *
+   * This refers to "fields" in the JSON:API sense of the word. Config entities
+   * do not have real fields, so in that case, this will be an array of values
+   * for config entity attributes.
+   *
+   * @var \Drupal\Core\Field\FieldItemListInterface[]|mixed[]
+   */
+  protected $fields;
+
+  /**
+   * The entity represented by this resource object.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The resource object's links.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\LinkCollection
+   */
+  protected $links;
+
+  /**
+   * ResourceObject constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type of the resource object.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be represented by this resource object.
+   * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
+   *   (optional) Any links for the resource object, if a `self` link is not
+   *   provided, one will be automatically added if the resource is locatable
+   *   and is not an internal entity.
+   */
+  public function __construct(ResourceType $resource_type, EntityInterface $entity, LinkCollection $links = NULL) {
+    $this->setCacheability($entity);
+    $this->resourceType = $resource_type;
+    $this->entity = $entity;
+    $this->fields = $this->extractFields($entity);
+    $this->resourceIdentifier = new ResourceIdentifier($resource_type, $this->entity->uuid());
+    $this->links = is_null($links) ? (new LinkCollection([]))->withContext($this) : $links->withContext($this);
+    if ($resource_type->isLocatable() && !$resource_type->isInternal() && !$this->links->hasLinkWithKey('self')) {
+      $self_link = Url::fromRoute(Routes::getRouteName($this->getResourceType(), 'individual'), ['entity' => $this->getId()]);
+      $this->links = $this->links->withLink('self', new Link(new CacheableMetadata(), $self_link, ['self']));
+    }
+  }
+
+  /**
+   * Whether the resource object has the given field.
+   *
+   * @param string $public_field_name
+   *   A public field name.
+   *
+   * @return bool
+   *   TRUE if the resource object has the given field, FALSE otherwise.
+   */
+  public function hasField($public_field_name) {
+    return isset($this->fields[$public_field_name]);
+  }
+
+  /**
+   * Gets the given field.
+   *
+   * @param string $public_field_name
+   *   A public field name.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface|null
+   *   The field or NULL if the resource object does not have the given field.
+   *
+   * @see ::extractFields()
+   */
+  public function getField($public_field_name) {
+    return $this->hasField($public_field_name) ? $this->fields[$public_field_name] : NULL;
+  }
+
+  /**
+   * Gets the ResourceObject's fields.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
+   *   The resource object's fields.
+   *
+   * @see ::extractFields()
+   */
+  public function getFields() {
+    return $this->fields;
+  }
+
+  /**
+   * Gets the ResourceObject's links.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   The resource object's links.
+   */
+  public function getLinks() {
+    return $this->links;
+  }
+
+  /**
+   * Gets a Url for the ResourceObject.
+   *
+   * @return \Drupal\Core\Url
+   *   The URL for the identified resource object.
+   *
+   * @throws \LogicException
+   *   Thrown if the resource object is not locatable.
+   *
+   * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::isLocatableResourceType()
+   */
+  public function toUrl() {
+    foreach ($this->links as $key => $link) {
+      if ($key === 'self') {
+        $first = reset($link);
+        return $first->getUri();
+      }
+    }
+    throw new \LogicException('A Url does not exist for this resource object because its resource type is not locatable.');
+  }
+
+  /**
+   * Extracts the entity's fields.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity from which fields should be extracted.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
+   *   If the resource object represents a content entity, the fields will be
+   *   objects satisfying FieldItemListInterface. If it represents a config
+   *   entity, the fields will be scalar values or arrays.
+   */
+  protected function extractFields(EntityInterface $entity) {
+    assert($entity instanceof ContentEntityInterface || $entity instanceof ConfigEntityInterface);
+    return $entity instanceof ContentEntityInterface
+      ? $this->extractContentEntityFields($entity)
+      : $this->extractConfigEntityFields($entity);
+  }
+
+  /**
+   * Extracts a content entity's fields.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The config entity from which fields should be extracted.
+   *
+   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   *   The fields extracted from a content entity.
+   */
+  protected function extractContentEntityFields(ContentEntityInterface $entity) {
+    $output = [];
+    $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(
+      array_keys($fields),
+      [$this->resourceType, 'isFieldEnabled']
+    );
+
+    // The "label" field needs special treatment: some entity types have a label
+    // field that is actually backed by a label callback.
+    $entity_type = $entity->getEntityType();
+    if ($entity_type->hasLabelCallback()) {
+      $fields[$this->getLabelFieldName()]->value = $entity->label();
+    }
+
+    // Return a sub-array of $output containing the keys in $enabled_fields.
+    $input = array_intersect_key($fields, array_flip($enabled_field_names));
+    foreach ($input as $field_name => $field_value) {
+      $public_field_name = $this->resourceType->getPublicName($field_name);
+      $output[$public_field_name] = $field_value;
+    }
+    return $output;
+  }
+
+  /**
+   * Determines the entity type's (internal) label field name.
+   *
+   * @return string
+   *   The label field name.
+   */
+  protected function getLabelFieldName() {
+    $label_field_name = $this->entity->getEntityType()->getKey('label');
+    // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands.
+    if ($this->entity->getEntityTypeId() === 'user') {
+      $label_field_name = 'name';
+    }
+    return $label_field_name;
+  }
+
+  /**
+   * Extracts a config entity's fields.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+   *   The config entity from which fields should be extracted.
+   *
+   * @return array
+   *   The fields extracted from a config entity.
+   */
+  protected function extractConfigEntityFields(ConfigEntityInterface $entity) {
+    $enabled_public_fields = [];
+    $fields = $entity->toArray();
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(array_keys($fields), function ($internal_field_name) {
+      // Config entities have "fields" which aren't known to the resource type,
+      // these fields should not be excluded because they cannot be enabled or
+      // disabled.
+      return !$this->resourceType->hasField($internal_field_name) || $this->resourceType->isFieldEnabled($internal_field_name);
+    });
+    // Return a sub-array of $output containing the keys in $enabled_fields.
+    $input = array_intersect_key($fields, array_flip($enabled_field_names));
+    /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
+    foreach ($input as $field_name => $field_value) {
+      $public_field_name = $this->resourceType->getPublicName($field_name);
+      $enabled_public_fields[$public_field_name] = $field_value;
+    }
+    return $enabled_public_fields;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiSpec.php b/core/modules/jsonapi/src/JsonApiSpec.php
new file mode 100644
index 0000000000..9b5b6d6ea7
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiSpec.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+/**
+ * Defines constants used for compliance with the JSON:API specification.
+ *
+ * @see http://jsonapi.org/format
+ *
+ * @internal
+ */
+class JsonApiSpec {
+
+  /**
+   * The minimum supported specification version.
+   *
+   * @see http://jsonapi.org/format/#document-jsonapi-object
+   */
+  const SUPPORTED_SPECIFICATION_VERSION = '1.0';
+
+  /**
+   * The URI of the supported specification document.
+   */
+  const SUPPORTED_SPECIFICATION_PERMALINK = 'http://jsonapi.org/format/1.0/';
+
+  /**
+   * Member name: globally allowed characters.
+   *
+   * U+0080 and above (non-ASCII Unicode characters) are allowed, but are not
+   * URL-safe. It is RECOMMENDED to not use them.
+   *
+   * A character class, for use in regular expressions.
+   *
+   * @see http://jsonapi.org/format/#document-member-names-allowed-characters
+   * @see http://php.net/manual/en/regexp.reference.character-classes.php
+   */
+  const MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS = '[a-zA-Z0-9\x{80}-\x{10FFFF}]';
+
+  /**
+   * Member name: allowed characters except as the first or last character.
+   *
+   * Space (U+0020) is allowed, but is not URL-safe. It is RECOMMENDED to not
+   * use it.
+   *
+   * A character class, for use in regular expressions.
+   *
+   * @see http://jsonapi.org/format/#document-member-names-allowed-characters
+   * @see http://php.net/manual/en/regexp.reference.character-classes.php
+   */
+  const MEMBER_NAME_INNER_ALLOWED_CHARACTERS = "[a-zA-Z0-9\x{80}-\x{10FFFF}\-_ ]";
+
+  /**
+   * Checks whether the given member name is valid.
+   *
+   * Requirements:
+   * - it MUST contain at least one character.
+   * - it MUST contain only the allowed characters
+   * - it MUST start and end with a "globally allowed character"
+   *
+   * @param string $member_name
+   *   A member name to validate.
+   *
+   * @return bool
+   *   Whether the given member name is in compliance with the JSON:API
+   *   specification.
+   *
+   * @see http://jsonapi.org/format/#document-member-names
+   */
+  public static function isValidMemberName($member_name) {
+    // @todo When D8 requires PHP >=5.6, move to a MEMBER_NAME_REGEXP constant.
+    static $regexp;
+    // @codingStandardsIgnoreStart
+    if (!isset($regexp)) {
+      $regexp = '/^' .
+        // First character must be "globally allowed". Length must be >=1.
+        self::MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS . '{1}' .
+        '(' .
+          // As many non-globally allowed characters as desired.
+          self::MEMBER_NAME_INNER_ALLOWED_CHARACTERS . '*' .
+          // If length > 1, then it must end in a "globally allowed" character.
+          self::MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS . '{1}' .
+        // >1 characters is optional.
+        ')?' .
+        '$/u';
+    }
+    // @codingStandardsIgnoreEnd
+
+    return preg_match($regexp, $member_name) === 1;
+  }
+
+  /**
+   * The reserved (official) query parameters.
+   *
+   * @todo When D8 requires PHP >= 5.6, convert to an array.
+   */
+  const RESERVED_QUERY_PARAMETERS = 'filter|sort|page|fields|include';
+
+  /**
+   * The query parameter for providing a version (revision) value.
+   *
+   * @var string
+   */
+  const VERSION_QUERY_PARAMETER = 'resourceVersion';
+
+  /**
+   * Gets the reserved (official) JSON:API query parameters.
+   *
+   * @return string[]
+   *   Gets the query parameters reserved by the specification.
+   */
+  public static function getReservedQueryParameters() {
+    return explode('|', static::RESERVED_QUERY_PARAMETERS);
+  }
+
+  /**
+   * Checks whether the given custom query parameter name is valid.
+   *
+   * A custom query parameter name must be a valid member name, with one
+   * additional requirement: it MUST contain at least one non a-z character.
+   *
+   * Requirements:
+   * - it MUST contain at least one character.
+   * - it MUST contain only the allowed characters
+   * - it MUST start and end with a "globally allowed character"
+   * - it MUST contain at least none a-z (U+0061 to U+007A) character
+   *
+   * It is RECOMMENDED that a hyphen (U+002D), underscore (U+005F) or capital
+   * letter is used (i.e. camelCasing).
+   *
+   * @param string $custom_query_parameter_name
+   *   A custom query parameter name to validate.
+   *
+   * @return bool
+   *   Whether the given query parameter is in compliane with the JSON:API
+   *   specification.
+   *
+   * @see http://jsonapi.org/format/#query-parameters
+   */
+  public static function isValidCustomQueryParameter($custom_query_parameter_name) {
+    return static::isValidMemberName($custom_query_parameter_name) && preg_match('/[^a-z]/u', $custom_query_parameter_name) === 1;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonapiServiceProvider.php b/core/modules/jsonapi/src/JsonapiServiceProvider.php
new file mode 100644
index 0000000000..7ca2183288
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonapiServiceProvider.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+use Drupal\Core\DependencyInjection\ServiceProviderInterface;
+use Drupal\Core\StackMiddleware\NegotiationMiddleware;
+use Drupal\jsonapi\DependencyInjection\Compiler\RegisterSerializationClassesCompilerPass;
+
+/**
+ * Adds 'api_json' as known format and prevents its use in the REST module.
+ *
+ * @internal
+ */
+class JsonapiServiceProvider implements ServiceModifierInterface, ServiceProviderInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    if ($container->has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, TRUE)) {
+      // @see http://www.iana.org/assignments/media-types/application/vnd.api+json
+      $container->getDefinition('http_middleware.negotiation')
+        ->addMethodCall('registerFormat', [
+          'api_json',
+          ['application/vnd.api+json'],
+        ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    $container->addCompilerPass(new RegisterSerializationClassesCompilerPass());
+  }
+
+}
diff --git a/core/modules/jsonapi/src/LinkManager/LinkManager.php b/core/modules/jsonapi/src/LinkManager/LinkManager.php
new file mode 100644
index 0000000000..49cef5b35a
--- /dev/null
+++ b/core/modules/jsonapi/src/LinkManager/LinkManager.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace Drupal\jsonapi\LinkManager;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Query\OffsetPage;
+use Symfony\Component\HttpFoundation\Request;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * Class to generate links and queries for entities.
+ *
+ * @deprecated
+ * @internal
+ *
+ * @todo Remove this as part of https://www.drupal.org/project/jsonapi/issues/2994193
+ */
+class LinkManager {
+
+  /**
+   * Used to generate a link to a jsonapi representation of an entity.
+   *
+   * @var \Drupal\Core\Render\MetadataBubblingUrlGenerator
+   */
+  protected $urlGenerator;
+
+  /**
+   * Instantiates a LinkManager object.
+   *
+   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
+   *   The Url generator.
+   */
+  public function __construct(UrlGeneratorInterface $url_generator) {
+    $this->urlGenerator = $url_generator;
+  }
+
+  /**
+   * Gets a link for the entity.
+   *
+   * @param int $entity_id
+   *   The entity ID to generate the link for. Note: Depending on the
+   *   configuration this might be the UUID as well.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type.
+   * @param array $route_parameters
+   *   Parameters for the route generation.
+   * @param string $key
+   *   A key to build the route identifier.
+   *
+   * @return string|null
+   *   The URL string, or NULL if the given entity is not locatable.
+   */
+  public function getEntityLink($entity_id, ResourceType $resource_type, array $route_parameters, $key) {
+    if (!$resource_type->isLocatable()) {
+      return NULL;
+    }
+
+    $route_parameters += [
+      'entity' => $entity_id,
+    ];
+    $route_key = sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $key);
+    return $this->urlGenerator->generateFromRoute($route_key, $route_parameters, ['absolute' => TRUE], TRUE)->getGeneratedUrl();
+  }
+
+  /**
+   * Get the full URL for a given request object.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param array|null $query
+   *   The query parameters to use. Leave it empty to get the query from the
+   *   request object.
+   *
+   * @return \Drupal\Core\Url
+   *   The full URL.
+   */
+  public function getRequestLink(Request $request, $query = NULL) {
+    if ($query === NULL) {
+      return Url::fromUri($request->getUri());
+    }
+
+    $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
+    return Url::fromUri($uri_without_query_string)->setOption('query', $query);
+  }
+
+  /**
+   * Get the pager links for a given request object.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param \Drupal\jsonapi\Query\OffsetPage $page_param
+   *   The current pagination parameter for the requested collection.
+   * @param array $link_context
+   *   An associative array with extra data to build the links.
+   *
+   * @return \Drupal\jsonapi\JsonApiResource\LinkCollection
+   *   An LinkCollection, with:
+   *   - a 'next' key if it is not the last page;
+   *   - 'prev' and 'first' keys if it's not the first page.
+   */
+  public function getPagerLinks(Request $request, OffsetPage $page_param, array $link_context = []) {
+    $pager_links = new LinkCollection([]);
+    if (!empty($link_context['total_count']) && !$total = (int) $link_context['total_count']) {
+      return $pager_links;
+    }
+    /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
+    $offset = $page_param->getOffset();
+    $size = $page_param->getSize();
+    if ($size <= 0) {
+      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']);
+      throw new CacheableBadRequestHttpException($cacheability, sprintf('The page size needs to be a positive integer.'));
+    }
+    $query = (array) $request->query->getIterator();
+    // Check if this is not the last page.
+    if ($link_context['has_next_page']) {
+      $next_url = $this->getRequestLink($request, $this->getPagerQueries('next', $offset, $size, $query));
+      $pager_links = $pager_links->withLink('next', new Link(new CacheableMetadata(), $next_url, ['next']));
+
+      if (!empty($total)) {
+        $last_url = $this->getRequestLink($request, $this->getPagerQueries('last', $offset, $size, $query, $total));
+        $pager_links = $pager_links->withLink('last', new Link(new CacheableMetadata(), $last_url, ['last']));
+      }
+    }
+
+    // Check if this is not the first page.
+    if ($offset > 0) {
+      $first_url = $this->getRequestLink($request, $this->getPagerQueries('first', $offset, $size, $query));
+      $pager_links = $pager_links->withLink('first', new Link(new CacheableMetadata(), $first_url, ['first']));
+      $prev_url = $this->getRequestLink($request, $this->getPagerQueries('prev', $offset, $size, $query));
+      $pager_links = $pager_links->withLink('prev', new Link(new CacheableMetadata(), $prev_url, ['prev']));
+    }
+
+    return $pager_links;
+  }
+
+  /**
+   * Get the query param array.
+   *
+   * @param string $link_id
+   *   The name of the pagination link requested.
+   * @param int $offset
+   *   The starting index.
+   * @param int $size
+   *   The pagination page size.
+   * @param array $query
+   *   The query parameters.
+   * @param int $total
+   *   The total size of the collection.
+   *
+   * @return array
+   *   The pagination query param array.
+   */
+  protected function getPagerQueries($link_id, $offset, $size, array $query = [], $total = 0) {
+    $extra_query = [];
+    switch ($link_id) {
+      case 'next':
+        $extra_query = [
+          'page' => [
+            'offset' => $offset + $size,
+            'limit' => $size,
+          ],
+        ];
+        break;
+
+      case 'first':
+        $extra_query = [
+          'page' => [
+            'offset' => 0,
+            'limit' => $size,
+          ],
+        ];
+        break;
+
+      case 'last':
+        if ($total) {
+          $extra_query = [
+            'page' => [
+              'offset' => (ceil($total / $size) - 1) * $size,
+              'limit' => $size,
+            ],
+          ];
+        }
+        break;
+
+      case 'prev':
+        $extra_query = [
+          'page' => [
+            'offset' => max($offset - $size, 0),
+            'limit' => $size,
+          ],
+        ];
+        break;
+    }
+    return array_merge($query, $extra_query);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ConfigEntityDenormalizer.php b/core/modules/jsonapi/src/Normalizer/ConfigEntityDenormalizer.php
new file mode 100644
index 0000000000..d53f8b1d03
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ConfigEntityDenormalizer.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Converts the Drupal config entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+final class ConfigEntityDenormalizer extends EntityDenormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
+    $prepared = [];
+    foreach ($data as $key => $value) {
+      $prepared[$resource_type->getInternalName($key)] = $value;
+    }
+    return $prepared;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php
new file mode 100644
index 0000000000..5c5157ac5f
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+
+/**
+ * Converts a JSON:API array structure into a Drupal entity object.
+ *
+ * @internal
+ */
+final class ContentEntityDenormalizer extends EntityDenormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = ContentEntityInterface::class;
+
+  /**
+   * Prepares the input data to create the entity.
+   *
+   * @param array $data
+   *   The input data to modify.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   Contains the info about the resource type.
+   * @param string $format
+   *   Format the given data was extracted from.
+   * @param array $context
+   *   Options available to the denormalizer.
+   *
+   * @return array
+   *   The modified input data.
+   */
+  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
+    $data_internal = [];
+
+    $field_map = $this->fieldManager->getFieldMap()[$resource_type->getEntityTypeId()];
+
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id);
+    $bundle_key = $entity_type_definition->getKey('bundle');
+    $uuid_key = $entity_type_definition->getKey('uuid');
+
+    // Translate the public fields into the entity fields.
+    foreach ($data as $public_field_name => $field_value) {
+      $internal_name = $resource_type->getInternalName($public_field_name);
+
+      // Skip any disabled field, except the always required bundle key and
+      // required-in-case-of-PATCHing uuid key.
+      // @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+      if ($resource_type->hasField($internal_name) && !$resource_type->isFieldEnabled($internal_name) && $bundle_key !== $internal_name && $uuid_key !== $internal_name) {
+        continue;
+      }
+
+      if (!isset($field_map[$internal_name]) || !in_array($resource_type->getBundle(), $field_map[$internal_name]['bundles'], TRUE)) {
+        throw new UnprocessableEntityHttpException(sprintf(
+          'The attribute %s does not exist on the %s resource type.',
+          $internal_name,
+          $resource_type->getTypeName()
+        ));
+      }
+
+      $field_type = $field_map[$internal_name]['type'];
+      $field_class = $this->pluginManager->getDefinition($field_type)['list_class'];
+
+      $field_denormalization_context = array_merge($context, [
+        'field_type' => $field_type,
+        'field_name' => $internal_name,
+        'field_definition' => $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle())[$internal_name],
+      ]);
+      $data_internal[$internal_name] = $this->serializer->denormalize($field_value, $field_class, $format, $field_denormalization_context);
+    }
+
+    return $data_internal;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php
new file mode 100644
index 0000000000..5033d8bbbe
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Url;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Normalizes an EntityAccessDeniedException.
+ *
+ * Normalizes an EntityAccessDeniedException in compliance with the JSON:API
+ * specification. A source pointer is added to help client applications report
+ * which entity was access denied.
+ *
+ * @see http://jsonapi.org/format/#error-objects
+ *
+ * @internal
+ */
+class EntityAccessDeniedHttpExceptionNormalizer extends HttpExceptionNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityAccessDeniedHttpException::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function buildErrorObjects(HttpException $exception) {
+    $errors = parent::buildErrorObjects($exception);
+
+    if ($exception instanceof EntityAccessDeniedHttpException) {
+      $error = $exception->getError();
+      /** @var \Drupal\Core\Entity\EntityInterface $entity */
+      $entity = $error['entity'];
+      $pointer = $error['pointer'];
+      $reason = $error['reason'];
+      $relationship_field = isset($error['relationship_field'])
+        ? $error['relationship_field']
+        : NULL;
+
+      if (isset($entity)) {
+        $entity_type_id = $entity->getEntityTypeId();
+        $bundle = $entity->bundle();
+        /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+        $resource_type = \Drupal::service('jsonapi.resource_type.repository')->get($entity_type_id, $bundle);
+        $resource_type_name = $resource_type->getTypeName();
+        $route_name = !is_null($relationship_field)
+          ? "jsonapi.$resource_type_name.$relationship_field.related"
+          : "jsonapi.$resource_type_name.individual";
+        $url = Url::fromRoute($route_name, ['entity' => $entity->uuid()]);
+        $errors[0]['links']['via']['href'] = $url->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+      }
+      $errors[0]['source']['pointer'] = $pointer;
+
+      if ($reason) {
+        $errors[0]['detail'] = isset($errors[0]['detail']) ? $errors[0]['detail'] . ' ' . $reason : $reason;
+      }
+    }
+
+    return $errors;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityDenormalizerBase.php b/core/modules/jsonapi/src/Normalizer/EntityDenormalizerBase.php
new file mode 100644
index 0000000000..af84b9a525
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityDenormalizerBase.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+abstract class EntityDenormalizerBase extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The field plugin manager.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * Constructs an EntityDenormalizerBase object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
+   *   The plugin manager for fields.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    throw new \LogicException('This method should never be called.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) {
+      throw new PreconditionFailedHttpException('Missing context during denormalization.');
+    }
+    /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+    $resource_type = $context['resource_type'];
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $bundle = $resource_type->getBundle();
+    $bundle_key = $this->entityTypeManager->getDefinition($entity_type_id)
+      ->getKey('bundle');
+    if ($bundle_key && $bundle) {
+      $data[$bundle_key] = $bundle;
+    }
+
+    return $this->entityTypeManager->getStorage($entity_type_id)
+      ->create($this->prepareInput($data, $resource_type, $format, $context));
+  }
+
+  /**
+   * Prepares the input data to create the entity.
+   *
+   * @param array $data
+   *   The input data to modify.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   Contains the info about the resource type.
+   * @param string $format
+   *   Format the given data was extracted from.
+   * @param array $context
+   *   Options available to the denormalizer.
+   *
+   * @return array
+   *   The modified input data.
+   */
+  abstract protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context);
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
new file mode 100644
index 0000000000..1d56b69259
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Normalizer class specific for entity reference field objects.
+ *
+ * @internal
+ */
+class EntityReferenceFieldNormalizer extends FieldNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * Instantiates a EntityReferenceFieldNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   */
+  public function __construct(LinkManager $link_manager) {
+    $this->linkManager = $link_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = []) {
+    assert($field instanceof EntityReferenceFieldItemListInterface);
+    // Build the relationship object based on the Entity Reference and normalize
+    // that object instead.
+    $definition = $field->getFieldDefinition();
+    $cardinality = $definition
+      ->getFieldStorageDefinition()
+      ->getCardinality();
+    $resource_identifiers = array_filter(ResourceIdentifier::toResourceIdentifiers($field->filterEmptyItems()), function (ResourceIdentifierInterface $resource_identifier) {
+      return !$resource_identifier->getResourceType()->isInternal();
+    });
+    $context['field_name'] = $field->getName();
+    $normalized_items = CacheableNormalization::aggregate($this->serializer->normalize($resource_identifiers, $format, $context));
+    assert($context['resource_object'] instanceof ResourceIdentifierInterface);
+    $resource_type = $context['resource_object']->getResourceType();
+    $field_name = $resource_type->getPublicName($field->getName());
+    $links = $this->getLinks($resource_type, $field_name, $field->getEntity()->uuid());
+    $normalization = $normalized_items->getNormalization();
+    return (new CacheableNormalization($normalized_items, [
+      // Empty 'to-one' relationships must be NULL.
+      // Empty 'to-many' relationships must be an empty array.
+      // @link http://jsonapi.org/format/#document-resource-object-linkage
+      'data' => $cardinality === 1 ? array_shift($normalization) : $normalization,
+      'links' => $links,
+    ]));
+  }
+
+  /**
+   * Gets the links for the relationship.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type on which the relationship being normalized
+   *   resides.
+   * @param string $field_name
+   *   The field name for the relationship.
+   * @param string $host_entity_id
+   *   The ID of the entity on which the relationship resides.
+   *
+   * @return array
+   *   An array of links to be rasterized.
+   */
+  protected function getLinks(ResourceType $resource_type, $field_name, $host_entity_id) {
+    $relationship_field_name = $resource_type->getPublicName($field_name);
+    $route_parameters = [
+      'related' => $relationship_field_name,
+    ];
+    $links['self']['href'] = $this->linkManager->getEntityLink(
+      $host_entity_id,
+      $resource_type,
+      $route_parameters,
+      "$relationship_field_name.relationship.get"
+    );
+    $resource_types = $resource_type->getRelatableResourceTypesByField($field_name);
+    if (static::hasNonInternalResourceType($resource_types)) {
+      $links['related']['href'] = $this->linkManager->getEntityLink(
+        $host_entity_id,
+        $resource_type,
+        $route_parameters,
+        "$relationship_field_name.related"
+      );
+    }
+    return $links;
+  }
+
+  /**
+   * Determines if a given list of resource types contains a non-internal type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The JSON:API resource types to evaluate.
+   *
+   * @return bool
+   *   FALSE if every resource type is internal, TRUE otherwise.
+   */
+  protected static function hasNonInternalResourceType(array $resource_types) {
+    foreach ($resource_types as $resource_type) {
+      if (!$resource_type->isInternal()) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
new file mode 100644
index 0000000000..36e357fed6
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal field item object to a JSON:API array structure.
+ *
+ * @internal
+ */
+class FieldItemNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = FieldItemInterface::class;
+
+  /**
+   * {@inheritdoc}
+   *
+   * This normalizer leaves JSON:API normalizer land and enters the land of
+   * Drupal core's serialization system. That system was never designed with
+   * cacheability in mind, and hence bubbles cacheability out of band. This must
+   * catch it, and pass it to the value object that JSON:API uses.
+   */
+  public function normalize($field_item, $format = NULL, array $context = []) {
+    /** @var \Drupal\Core\TypedData\TypedDataInterface $property */
+    $values = [];
+    // We normalize each individual value, so each can do their own casting,
+    // if needed.
+    $field_properties = !empty($field_item->getProperties(TRUE))
+      ? TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item)
+      : $field_item->getValue();
+
+    $context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY] = new CacheableMetadata();
+
+    foreach ($field_properties as $property_name => $property) {
+      $values[$property_name] = $this->serializer->normalize($property, $format, $context);
+    }
+
+    if (isset($context['langcode'])) {
+      $values['lang'] = $context['langcode'];
+    }
+    $normalization = new CacheableNormalization(
+      $context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY],
+      static::rasterizeValueRecursive(count($values) == 1 ? reset($values) : $values)
+    );
+    unset($context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $item_definition = $context['field_definition']->getItemDefinition();
+    assert($item_definition instanceof FieldItemDataDefinitionInterface);
+
+    $property_definitions = $item_definition->getPropertyDefinitions();
+
+    // Because e.g. the 'bundle' entity key field requires field values to not
+    // be expanded to an array of all properties, we special-case single-value
+    // properties.
+    if (!is_array($data)) {
+      $property_value = $data;
+      $property_value_class = $property_definitions[$item_definition->getMainPropertyName()]->getClass();
+      return $this->serializer->supportsDenormalization($property_value, $property_value_class, $format, $context)
+        ? $this->serializer->denormalize($property_value, $property_value_class, $format, $context)
+        : $property_value;
+    }
+
+    $data_internal = [];
+    if (!empty($property_definitions)) {
+      foreach ($data as $property_name => $property_value) {
+        $property_value_class = $property_definitions[$property_name]->getClass();
+        $data_internal[$property_name] = $this->serializer->supportsDenormalization($property_value, $property_value_class, $format, $context)
+          ? $this->serializer->denormalize($property_value, $property_value_class, $format, $context)
+          : $property_value;
+      }
+    }
+    else {
+      $data_internal = $data;
+    }
+
+    return $data_internal;
+  }
+
+  /**
+   * Rasterizes a value recursively.
+   *
+   * This is mainly for configuration entities where a field can be a tree of
+   * values to rasterize.
+   *
+   * @param mixed $value
+   *   Either a scalar, an array or a rasterizable object.
+   *
+   * @return mixed
+   *   The rasterized value.
+   */
+  protected static function rasterizeValueRecursive($value) {
+    if (!$value || is_scalar($value)) {
+      return $value;
+    }
+    if (is_array($value)) {
+      $output = [];
+      foreach ($value as $key => $item) {
+        $output[$key] = static::rasterizeValueRecursive($item);
+      }
+
+      return $output;
+    }
+    if ($value instanceof CacheableNormalization) {
+      return $value->getNormalization();
+    }
+    // If the object can be turned into a string it's better than nothing.
+    if (method_exists($value, '__toString')) {
+      return $value->__toString();
+    }
+
+    // We give up, since we do not know how to rasterize this.
+    return NULL;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
new file mode 100644
index 0000000000..ee5f43ad54
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal field structure to a JSON:API array structure.
+ *
+ * @internal
+ */
+class FieldNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = FieldItemListInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = []) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+    $normalized_items = $this->normalizeFieldItems($field, $format, $context);
+
+    $cardinality = $field->getFieldDefinition()
+      ->getFieldStorageDefinition()
+      ->getCardinality();
+
+    return $cardinality === 1
+      ? array_shift($normalized_items) ?: new CacheableNormalization(new CacheableMetadata(), NULL)
+      : CacheableNormalization::aggregate($normalized_items);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $field_definition = $context['field_definition'];
+    assert($field_definition instanceof FieldDefinitionInterface);
+
+    // If $data contains items (recognizable by numerical array keys, which
+    // Drupal's Field API calls "deltas"), then it already is itemized; it's not
+    // using the simplified JSON structure that JSON:API generates.
+    $is_already_itemized = is_array($data) && array_reduce(array_keys($data), function ($carry, $index) {
+      return $carry && is_numeric($index);
+    }, TRUE);
+
+    $itemized_data = $is_already_itemized
+      ? $data
+      : [0 => $data];
+
+    // Single-cardinality fields don't need itemization.
+    $field_item_class = $field_definition->getItemDefinition()->getClass();
+    if (count($itemized_data) === 1 && $field_definition->getFieldStorageDefinition()->getCardinality() === 1) {
+      return $this->serializer->denormalize($itemized_data[0], $field_item_class, $format, $context);
+    }
+
+    $data_internal = [];
+    foreach ($itemized_data as $delta => $field_item_value) {
+      $data_internal[$delta] = $this->serializer->denormalize($field_item_value, $field_item_class, $format, $context);
+    }
+
+    return $data_internal;
+  }
+
+  /**
+   * Helper function to normalize field items.
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface $field
+   *   The field object.
+   * @param string $format
+   *   The format.
+   * @param array $context
+   *   The context array.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue[]
+   *   The array of normalized field items.
+   */
+  protected function normalizeFieldItems(FieldItemListInterface $field, $format, array $context) {
+    $normalizer_items = [];
+    if (!$field->isEmpty()) {
+      foreach ($field as $field_item) {
+        $normalizer_items[] = $this->serializer->normalize($field_item, $format, $context);
+      }
+    }
+    return $normalizer_items;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
new file mode 100644
index 0000000000..a4459d28ac
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
@@ -0,0 +1,164 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Normalizer\Value\CacheableDependenciesMergerTrait;
+use Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Normalizes an HttpException in compliance with the JSON:API specification.
+ *
+ * @see http://jsonapi.org/format/#error-objects
+ *
+ * @internal
+ */
+class HttpExceptionNormalizer extends NormalizerBase {
+
+  use CacheableDependenciesMergerTrait;
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = HttpException::class;
+
+  /**
+   * The current user making the request.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * HttpExceptionNormalizer constructor.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   */
+  public function __construct(AccountInterface $current_user) {
+    $this->currentUser = $current_user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    return new HttpExceptionNormalizerValue(new CacheableMetadata(), $this->buildErrorObjects($object));
+  }
+
+  /**
+   * Builds the normalized JSON:API error objects for the response.
+   *
+   * @param \Symfony\Component\HttpKernel\Exception\HttpException $exception
+   *   The Exception.
+   *
+   * @return array
+   *   The error objects to include in the response.
+   */
+  protected function buildErrorObjects(HttpException $exception) {
+    $error = [];
+    $status_code = $exception->getStatusCode();
+    if (!empty(Response::$statusTexts[$status_code])) {
+      $error['title'] = Response::$statusTexts[$status_code];
+    }
+    $error += [
+      'status' => $status_code,
+      'detail' => $exception->getMessage(),
+    ];
+    $error['links']['via']['href'] = \Drupal::request()->getUri();
+    // Provide an "info" link by default: if the exception carries a single
+    // "Link" header, use that, otherwise fall back to the HTTP spec section
+    // covering the exception's status code.
+    $headers = $exception->getHeaders();
+    if (isset($headers['Link']) && !is_array($headers['Link'])) {
+      $error['links']['info']['href'] = $headers['Link'];
+    }
+    elseif ($info_url = $this->getInfoUrl($status_code)) {
+      $error['links']['info']['href'] = $info_url;
+    }
+    // Exceptions thrown without an explicitly defined code get assigned zero by
+    // default. Since this is no helpful information, omit it.
+    if ($exception->getCode() !== 0) {
+      $error['code'] = $exception->getCode();
+    }
+    if ($this->currentUser->hasPermission('access site reports')) {
+      // The following information may contain sensitive information. Only show
+      // it to authorized users.
+      $error['source'] = [
+        'file' => $exception->getFile(),
+        'line' => $exception->getLine(),
+      ];
+      $error['meta'] = [
+        'exception' => (string) $exception,
+        'trace' => $exception->getTrace(),
+      ];
+    }
+
+    return [$error];
+  }
+
+  /**
+   * Return a string to the common problem type.
+   *
+   * @return string|null
+   *   URL pointing to the specific RFC-2616 section. Or NULL if it is an HTTP
+   *   status code that is defined in another RFC.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2832211#comment-11826234
+   *
+   * @internal
+   */
+  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 = [
+      '100' => '#sec10.1.1',
+      '101' => '#sec10.1.2',
+      '200' => '#sec10.2.1',
+      '201' => '#sec10.2.2',
+      '202' => '#sec10.2.3',
+      '203' => '#sec10.2.4',
+      '204' => '#sec10.2.5',
+      '205' => '#sec10.2.6',
+      '206' => '#sec10.2.7',
+      '300' => '#sec10.3.1',
+      '301' => '#sec10.3.2',
+      '302' => '#sec10.3.3',
+      '303' => '#sec10.3.4',
+      '304' => '#sec10.3.5',
+      '305' => '#sec10.3.6',
+      '307' => '#sec10.3.8',
+      '400' => '#sec10.4.1',
+      '401' => '#sec10.4.2',
+      '402' => '#sec10.4.3',
+      '403' => '#sec10.4.4',
+      '404' => '#sec10.4.5',
+      '405' => '#sec10.4.6',
+      '406' => '#sec10.4.7',
+      '407' => '#sec10.4.8',
+      '408' => '#sec10.4.9',
+      '409' => '#sec10.4.10',
+      '410' => '#sec10.4.11',
+      '411' => '#sec10.4.12',
+      '412' => '#sec10.4.13',
+      '413' => '#sec10.4.14',
+      '414' => '#sec10.4.15',
+      '415' => '#sec10.4.16',
+      '416' => '#sec10.4.17',
+      '417' => '#sec10.4.18',
+      '500' => '#sec10.5.1',
+      '501' => '#sec10.5.2',
+      '502' => '#sec10.5.3',
+      '503' => '#sec10.5.4',
+      '504' => '#sec10.5.5',
+      '505' => '#sec10.5.6',
+    ];
+    return empty($sections[$status_code]) ? NULL : $url . $sections[$status_code];
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
new file mode 100644
index 0000000000..4719b87b8f
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -0,0 +1,449 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Uuid\Uuid;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\JsonApiSpec;
+use Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+
+/**
+ * Normalizes the top-level document according to the JSON:API specification.
+ *
+ * @see \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel
+ *
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements DenormalizerInterface, NormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = JsonApiDocumentTopLevel::class;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * Constructs a JsonApiDocumentTopLevelNormalizer object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $resource_type = $context['resource_type'];
+
+    // Validate a few common errors in document formatting.
+    static::validateRequestBody($data, $resource_type);
+
+    $normalized = [];
+
+    if (!empty($data['data']['attributes'])) {
+      $normalized = $data['data']['attributes'];
+    }
+
+    if (!empty($data['data']['id'])) {
+      $uuid_key = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId())->getKey('uuid');
+      $normalized[$uuid_key] = $data['data']['id'];
+    }
+
+    if (!empty($data['data']['relationships'])) {
+      // Turn all single object relationship data fields into an array of
+      // objects.
+      $relationships = array_map(function ($relationship) {
+        if (isset($relationship['data']['type']) && isset($relationship['data']['id'])) {
+          return ['data' => [$relationship['data']]];
+        }
+        else {
+          return $relationship;
+        }
+      }, $data['data']['relationships']);
+
+      // Get an array of ids for every relationship.
+      $relationships = array_map(function ($relationship) {
+        if (empty($relationship['data'])) {
+          return [];
+        }
+        if (empty($relationship['data'][0]['id'])) {
+          throw new BadRequestHttpException("No ID specified for related resource");
+        }
+        $id_list = array_column($relationship['data'], 'id');
+        if (empty($relationship['data'][0]['type'])) {
+          throw new BadRequestHttpException("No type specified for related resource");
+        }
+        if (!$resource_type = $this->resourceTypeRepository->getByTypeName($relationship['data'][0]['type'])) {
+          throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
+        }
+
+        $entity_type_id = $resource_type->getEntityTypeId();
+        try {
+          $entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
+        }
+        catch (PluginNotFoundException $e) {
+          throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
+        }
+        // In order to maintain the order ($delta) of the relationships, we need
+        // to load the entities and create a mapping between id and uuid.
+        $uuid_key = $this->entityTypeManager
+          ->getDefinition($entity_type_id)->getKey('uuid');
+        $related_entities = array_values($entity_storage->loadByProperties([$uuid_key => $id_list]));
+        $map = [];
+        foreach ($related_entities as $related_entity) {
+          $map[$related_entity->uuid()] = $related_entity->id();
+        }
+
+        // $id_list has the correct order of uuids. We stitch this together with
+        // $map which contains loaded entities, and then bring in the correct
+        // meta values from the relationship, whose deltas match with $id_list.
+        $canonical_ids = [];
+        foreach ($id_list as $delta => $uuid) {
+          if (!isset($map[$uuid])) {
+            // @see \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::normalize()
+            if ($uuid === 'virtual') {
+              continue;
+            }
+            throw new NotFoundHttpException(sprintf('The resource identified by `%s:%s` (given as a relationship item) could not be found.', $relationship['data'][$delta]['type'], $uuid));
+          }
+          $reference_item = [
+            'target_id' => $map[$uuid],
+          ];
+          if (isset($relationship['data'][$delta]['meta'])) {
+            $reference_item += $relationship['data'][$delta]['meta'];
+          }
+          $canonical_ids[] = $reference_item;
+        }
+
+        return array_filter($canonical_ids);
+      }, $relationships);
+
+      // Add the relationship ids.
+      $normalized = array_merge($normalized, $relationships);
+    }
+    // Override deserialization target class with the one in the ResourceType.
+    $class = $context['resource_type']->getDeserializationTargetClass();
+
+    return $this
+      ->serializer
+      ->denormalize($normalized, $class, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    $data = $object->getData();
+    if ($data instanceof ErrorCollection) {
+      $normalized = $this->normalizeErrorDocument($object, $format, $context);
+    }
+    elseif ($data instanceof EntityReferenceFieldItemListInterface) {
+      $normalized = $this->normalizeEntityReferenceFieldItemList($object, $format, $context);
+    }
+    else {
+      $normalized = $this->normalizeEntityCollection($object, $format, $context);
+    }
+    // Every JSON:API document contains absolute URLs.
+    return $normalized->withCacheableDependency((new CacheableMetadata())->addCacheContexts(['url.site']));
+  }
+
+  /**
+   * Normalizes an error collection.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
+   *   The document to normalize.
+   * @param string $format
+   *   The normalization format.
+   * @param array $context
+   *   The normalization context.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
+   *   The normalized document.
+   */
+  protected function normalizeErrorDocument(JsonApiDocumentTopLevel $document, $format, array $context = []) {
+    $data = $document->getData();
+    $normalizer_values = array_map(function (HttpExceptionInterface $exception) use ($format, $context) {
+      return $this->serializer->normalize($exception, $format, $context);
+    }, (array) $data->getIterator());
+    return $this->normalizeValues($document, $normalizer_values, $format, $context);
+  }
+
+  /**
+   * Normalizes an entity reference field, i.e. a relationship document.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
+   *   The document to normalize.
+   * @param string $format
+   *   The normalization format.
+   * @param array $context
+   *   The normalization context.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
+   *   The normalized document.
+   */
+  protected function normalizeEntityReferenceFieldItemList(JsonApiDocumentTopLevel $document, $format, array $context = []) {
+    $data = $document->getData();
+    $parent_entity = $data->getEntity();
+    $resource_type = $this->resourceTypeRepository->get($parent_entity->getEntityTypeId(), $parent_entity->bundle());
+    $context['resource_object'] = new ResourceObject($resource_type, $parent_entity);
+    $normalizer_values = [
+      $this->serializer->normalize($data, $format, $context),
+    ];
+    unset($context['resource_object']);
+    return $this->normalizeValues($document, $normalizer_values, $format, $context);
+  }
+
+  /**
+   * Normalizes an entity collection, i.e. an individual or collection document.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
+   *   The document to normalize.
+   * @param string $format
+   *   The normalization format.
+   * @param array $context
+   *   The normalization context.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
+   *   The normalized document.
+   */
+  protected function normalizeEntityCollection(JsonApiDocumentTopLevel $document, $format, array $context = []) {
+    $data = $document->getData();
+    $is_collection = $data instanceof EntityCollection;
+    // To improve the logical workflow deal with an array at all times.
+    $resource_objects = $is_collection ? $data->toArray() : [$data];
+    $normalizer_values = array_map(function ($entity) use ($format, $context) {
+      return $this->serializer->normalize($entity, $format, $context);
+    }, $resource_objects);
+    return $this->normalizeValues($document, $normalizer_values, $format, $context);
+  }
+
+  /**
+   * Normalizes a separates accessible includes and inaccessible omissions.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $collection
+   *   The includes entity collection.
+   * @param string $format
+   *   The normalization format.
+   * @param array $context
+   *   The normalization context.
+   *
+   * @return array
+   *   A tuple whose first value is an array of normalized entities to be
+   *   included and whose second value is an array of normalized
+   *   EntityAccessDeniedExceptions to be omitted.
+   */
+  protected function normalizeIncludesAndOmissions(EntityCollection $collection, $format, array $context = []) {
+    $includes = $omissions = [];
+    /* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface $resource_object */
+    foreach ($collection as $resource_object) {
+      $resource_object instanceof EntityAccessDeniedHttpException
+        ? $omissions[] = $this->serializer->normalize($resource_object, $format, $context)
+        : $includes[] = $this->serializer->normalize($resource_object, $format, $context);
+    }
+    return [$includes, $omissions];
+  }
+
+  /**
+   * Normalizes a document and its normalizer values.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
+   *   The document object.
+   * @param \Drupal\jsonapi\Normalizer\Value\CacheableNormalization[] $normalizer_values
+   *   The document's normalized error/data object(s).
+   * @param string $format
+   *   The normalization format.
+   * @param array $context
+   *   The normalization context.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
+   *   The normalized document.
+   */
+  protected function normalizeValues(JsonApiDocumentTopLevel $document, array $normalizer_values, $format, array $context = []) {
+    $is_error_document = $document->getData() instanceof ErrorCollection;
+    // Determine which of the two mutually exclusive top-level document members
+    // should be used.
+    $mutually_exclusive_member = $is_error_document ? 'errors' : 'data';
+    $rasterized = [
+      $mutually_exclusive_member => [],
+      'jsonapi' => [
+        'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
+        'meta' => [
+          'links' => [
+            'self' => [
+              'href' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK,
+            ],
+          ],
+        ],
+      ],
+    ];
+    if (!empty($document->getMeta())) {
+      $rasterized['meta'] = $document->getMeta();
+    }
+
+    $cacheability = new CacheableMetadata();
+    array_walk($normalizer_values, [$cacheability, 'addCacheableDependency']);
+
+    if ($is_error_document) {
+      foreach ($normalizer_values as $normalized_exception) {
+        $rasterized['errors'] = array_merge($rasterized['errors'], $normalized_exception->getNormalization());
+      }
+      return new CacheableNormalization($cacheability, $rasterized);
+    }
+
+    list($includes, $omissions) = $this->normalizeIncludesAndOmissions($document->getIncludes(), $format, $context);
+    array_walk($includes, [$cacheability, 'addCacheableDependency']);
+    array_walk($omissions, [$cacheability, 'addCacheableDependency']);
+
+    if (!empty($omissions)) {
+      $normalizer_values = array_merge($normalizer_values, $omissions);
+    }
+
+    $links = $this->serializer->normalize($document->getLinks(), $format, $context);
+    $rasterized['links'] = $links->getNormalization();
+    $cacheability->addCacheableDependency($links);
+
+    $link_hash_salt = Crypt::randomBytesBase64();
+    foreach ($normalizer_values as $normalizer_value) {
+      if ($normalizer_value instanceof HttpExceptionNormalizerValue) {
+        if (!isset($rasterized['meta']['omitted'])) {
+          $rasterized['meta']['omitted'] = [
+            'detail' => 'Some resources have been omitted because of insufficient authorization.',
+            'links' => [
+              'help' => [
+                'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
+              ],
+            ],
+          ];
+        }
+        // Add the errors to the pre-existing errors.
+        foreach ($normalizer_value->getNormalization() as $error) {
+          // JSON:API links cannot be arrays and the spec generally favors link
+          // relation types as keys. 'item' is the right link relation type, but
+          // we need multiple values. To do that, we generate a meaningless,
+          // random value to use as a unique key. That value is a hash of a
+          // random salt and the link href. This ensures that the key is non-
+          // deterministic while letting use deduplicate the links by their
+          // href. The salt is *not* used for any cryptographic reason.
+          $link_key = 'item:' . static::getLinkHash($link_hash_salt, $error['links']['via']['href']);
+          $rasterized['meta']['omitted']['links'][$link_key] = [
+            'href' => $error['links']['via']['href'],
+            'meta' => [
+              'rel' => 'item',
+              'detail' => $error['detail'],
+            ],
+          ];
+        }
+      }
+      else {
+        $rasterized_value = $normalizer_value->getNormalization();
+        if (array_key_exists('data', $rasterized_value) && array_key_exists('links', $rasterized_value)) {
+          $rasterized['data'][] = $rasterized_value['data'];
+          $rasterized['links'] = NestedArray::mergeDeep($rasterized['links'], $rasterized_value['links']);
+        }
+        else {
+          $rasterized['data'][] = $rasterized_value;
+        }
+      }
+    }
+    // Deal with the single entity case.
+    if ($document->getData() instanceof EntityCollection && $document->getData()->getCardinality() !== 1) {
+      $rasterized['data'] = array_filter($rasterized['data']);
+    }
+    else {
+      $rasterized['data'] = empty($rasterized['data']) ? NULL : reset($rasterized['data']);
+    }
+
+    if ($includes) {
+      $rasterized['included'] = array_map(function (CacheableNormalization $include) {
+        return $include->getNormalization();
+      }, $includes);
+    }
+
+    if (empty($rasterized['links'])) {
+      unset($rasterized['links']);
+    }
+
+    return new CacheableNormalization($cacheability, $rasterized);
+  }
+
+  /**
+   * Performs minimal validation of the document.
+   */
+  protected static function validateRequestBody(array $document, ResourceType $resource_type) {
+    // Ensure that the relationships key was not placed in the top level.
+    if (isset($document['relationships']) && !empty($document['relationships'])) {
+      throw new BadRequestHttpException("Found \"relationships\" within the document's top level. The \"relationships\" key must be within resource object.");
+    }
+    // Ensure that the resource object contains the "type" key.
+    if (!isset($document['data']['type'])) {
+      throw new BadRequestHttpException("Resource object must include a \"type\".");
+    }
+    // Ensure that the client provided ID is a valid UUID.
+    if (isset($document['data']['id']) && !Uuid::isValid($document['data']['id'])) {
+      throw new UnprocessableEntityHttpException('IDs should be properly generated and formatted UUIDs as described in RFC 4122.');
+    }
+    // Ensure that no relationship fields are being set via the attributes
+    // resource object member.
+    if (isset($document['data']['attributes'])) {
+      $received_attribute_field_names = array_keys($document['data']['attributes']);
+      $relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
+      if ($relationship_fields_sent_as_attributes = array_intersect($received_attribute_field_names, $relationship_field_names)) {
+        throw new UnprocessableEntityHttpException(sprintf("The following relationship fields were provided as attributes: [ %s ]", implode(', ', $relationship_fields_sent_as_attributes)));
+      }
+    }
+  }
+
+  /**
+   * Hashes an omitted link.
+   *
+   * @param string $salt
+   *   A hash salt.
+   * @param string $link_href
+   *   The omitted link.
+   *
+   * @return string
+   *   A 7 character hash.
+   */
+  protected static function getLinkHash($salt, $link_href) {
+    return substr(str_replace(['-', '_'], '', Crypt::hashBase64($salt . $link_href)), 0, 7);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
new file mode 100644
index 0000000000..5c40b0aafc
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/LinkCollectionNormalizer.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Utility\Crypt;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+
+/**
+ * Normalizes a LinkCollection object.
+ *
+ * The JSON:API specification has the concept of a "links collection". A links
+ * collection is a JSON object where each member of the object is a
+ * "link object". Unfortunately, this means that it is not possible to have more
+ * than one link for a given key.
+ *
+ * When normalizing more than one link in a LinkCollection with the same key, a
+ * unique and random string is appended to the link's key after a colon (:) to
+ * differentiate the links.
+ *
+ * This may change with a later version of the JSON:API specification.
+ *
+ * @internal
+ */
+class LinkCollectionNormalizer extends NormalizerBase {
+
+  /**
+   * The normalizer $context key name for the key of an individual link.
+   *
+   * @var string
+   */
+  const LINK_KEY = 'jsonapi_links_object_link_key';
+
+  /**
+   * The normalizer $context key name for the context object of the link.
+   *
+   * @var string
+   */
+  const LINK_CONTEXT = 'jsonapi_links_object_context';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = LinkCollection::class;
+
+  /**
+   * A random string to use when hashing links.
+   *
+   * This string is unique per instance of a link collection, but always the
+   * same within it. This means that link key hashes will be non-deterministic
+   * for outside observers, but two links within the same collection will always
+   * have the same hash value.
+   *
+   * This is not used for cryptographic purposes.
+   *
+   * @var string
+   */
+  protected $hashSalt;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert($object instanceof LinkCollection);
+    $normalized = [];
+    /* @var \Drupal\jsonapi\JsonApiResource\Link $link */
+    foreach ($object as $key => $links) {
+      $is_multiple = count($links) > 1;
+      foreach ($links as $link) {
+        $link_key = $is_multiple ? sprintf('%s:%s', $key, $this->hashByHref($link)) : $key;
+        $attributes = $link->getTargetAttributes();
+        $normalization = array_merge(['href' => $link->getHref()], !empty($attributes) ? ['meta' => $attributes] : []);
+        $normalized[$link_key] = new CacheableNormalization($link, $normalization);
+      }
+    }
+    return CacheableNormalization::aggregate($normalized);
+  }
+
+  /**
+   * Hashes a link by its href.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\Link $link
+   *   A link to be hashed.
+   *
+   * @return string
+   *   A 7 character alphanumeric hash.
+   */
+  protected function hashByHref(Link $link) {
+    if (!$this->hashSalt) {
+      $this->hashSalt = Crypt::randomBytesBase64();
+    }
+    return substr(str_replace(['-', '_'], '', Crypt::hashBase64($this->hashSalt . $link->getHref())), 0, 7);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/NormalizerBase.php b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
new file mode 100644
index 0000000000..709739c28f
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\serialization\Normalizer\NormalizerBase as SerializationNormalizerBase;
+
+/**
+ * Base normalizer used in all JSON:API normalizers.
+ *
+ * @internal
+ */
+abstract class NormalizerBase extends SerializationNormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $format = 'api_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkFormat($format = NULL) {
+    // The parent implementation allows format-specific normalizers to be used
+    // for formatless normalization. The JSON:API module wants to be cautious.
+    // Hence it only allows its normalizers to be used for the JSON:API format,
+    // to avoid JSON:API-specific normalizations showing up in the REST API.
+    return $format === $this->format;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ResourceIdentifierNormalizer.php b/core/modules/jsonapi/src/Normalizer/ResourceIdentifierNormalizer.php
new file mode 100644
index 0000000000..778e184dd8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ResourceIdentifierNormalizer.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Normalizes a Relationship according to the JSON:API specification.
+ *
+ * Normalizer class for relationship elements. A relationship can be anything
+ * that points to an entity in a JSON:API resource.
+ *
+ * @internal
+ */
+class ResourceIdentifierNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = ResourceIdentifier::class;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * RelationshipNormalizer constructor.
+   *
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   */
+  public function __construct(EntityFieldManagerInterface $field_manager) {
+    $this->fieldManager = $field_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert($object instanceof ResourceIdentifier);
+    $normalization = [
+      'type' => $object->getTypeName(),
+      'id' => $object->getId(),
+    ];
+    if ($object->getMeta()) {
+      $normalization['meta'] = $this->serializer->normalize($object->getMeta(), $format, $context);
+    }
+    return new CacheableNormalization(new CacheableMetadata(), $normalization);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    // If we get here, it's via a relationship POST/PATCH.
+    /** @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+    $resource_type = $context['resource_type'];
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $field_definitions = $this->fieldManager->getFieldDefinitions(
+      $entity_type_id,
+      $resource_type->getBundle()
+    );
+    if (empty($context['related']) || empty($field_definitions[$context['related']])) {
+      throw new BadRequestHttpException('Invalid or missing related field.');
+    }
+    /* @var \Drupal\field\Entity\FieldConfig $field_definition */
+    $field_definition = $field_definitions[$context['related']];
+    // This is typically 'target_id'.
+    $item_definition = $field_definition->getItemDefinition();
+    $property_key = $item_definition->getMainPropertyName();
+    $target_resource_types = $resource_type->getRelatableResourceTypesByField($resource_type->getPublicName($context['related']));
+    $target_resource_type_names = array_map(function (ResourceType $resource_type) {
+      return $resource_type->getTypeName();
+    }, $target_resource_types);
+
+    $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
+    $data = $this->massageRelationshipInput($data, $is_multiple);
+    $resource_identifiers = array_map(function ($value) use ($property_key, $target_resource_type_names) {
+      // Make sure that the provided type is compatible with the targeted
+      // resource.
+      if (!in_array($value['type'], $target_resource_type_names)) {
+        throw new BadRequestHttpException(sprintf(
+          'The provided type (%s) does not mach the destination resource types (%s).',
+          $value['type'],
+          implode(', ', $target_resource_type_names)
+        ));
+      }
+      return new ResourceIdentifier($value['type'], $value['id'], isset($value['meta']) ? $value['meta'] : []);
+    }, $data['data']);
+    if (!ResourceIdentifier::areResourceIdentifiersUnique($resource_identifiers)) {
+      throw new BadRequestHttpException('Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.');
+    }
+    return $resource_identifiers;
+  }
+
+  /**
+   * Validates and massages the relationship input depending on the cardinality.
+   *
+   * @param array $data
+   *   The input data from the body.
+   * @param bool $is_multiple
+   *   Indicates if the relationship is to-many.
+   *
+   * @return array
+   *   The massaged data array.
+   */
+  protected function massageRelationshipInput(array $data, $is_multiple) {
+    if ($is_multiple) {
+      if (!is_array($data['data'])) {
+        throw new BadRequestHttpException('Invalid body payload for the relationship.');
+      }
+      // Leave the invalid elements.
+      $invalid_elements = array_filter($data['data'], function ($element) {
+        return empty($element['type']) || empty($element['id']);
+      });
+      if ($invalid_elements) {
+        throw new BadRequestHttpException('Invalid body payload for the relationship.');
+      }
+    }
+    else {
+      // For to-one relationships you can have a NULL value.
+      if (is_null($data['data'])) {
+        return ['data' => []];
+      }
+      if (empty($data['data']['type']) || empty($data['data']['id'])) {
+        throw new BadRequestHttpException('Invalid body payload for the relationship.');
+      }
+      $data['data'] = [$data['data']];
+    }
+    return $data;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php
new file mode 100644
index 0000000000..728d9036f0
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
+
+/**
+ * Converts the JSON:API module ResourceObject into a JSON:API array structure.
+ *
+ * @internal
+ */
+class ResourceObjectNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = ResourceObject::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert($object instanceof ResourceObject);
+    // If the fields to use were specified, only output those field values.
+    $context['resource_object'] = $object;
+    $resource_type = $object->getResourceType();
+    $resource_type_name = $resource_type->getTypeName();
+    $fields = $object->getFields();
+    // Get the bundle ID of the requested resource. This is used to determine if
+    // this is a bundle level resource or an entity level resource.
+    if (!empty($context['sparse_fieldset'][$resource_type_name])) {
+      $field_names = $context['sparse_fieldset'][$resource_type_name];
+    }
+    else {
+      $field_names = array_keys($fields);
+    }
+    $normalizer_values = [];
+    foreach ($fields as $field_name => $field) {
+      $in_sparse_fieldset = in_array($field_name, $field_names);
+      // Omit fields not listed in sparse fieldsets.
+      if (!$in_sparse_fieldset) {
+        continue;
+      }
+      $normalizer_values[$field_name] = $this->serializeField($field, $context, $format);
+    }
+    // Create the array of normalized fields.
+    $normalized = [
+      'type' => $resource_type->getTypeName(),
+      'id' => $object->getId(),
+    ];
+    $links = $this->serializer->normalize($object->getLinks(), $format, $context);
+    assert($links instanceof CacheableNormalization);
+    $normalized['links'] = $links->getNormalization();
+    $relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
+    $attributes = CacheableNormalization::aggregate(array_diff_key($normalizer_values, array_flip($relationship_field_names)));
+    $relationships = CacheableNormalization::aggregate(array_intersect_key($normalizer_values, array_flip($relationship_field_names)));
+    $normalized['attributes'] = $attributes->getNormalization();
+    $normalized['relationships'] = $relationships->getNormalization();
+    return (new CacheableNormalization($object, array_filter($normalized)))->withCacheableDependency($attributes)->withCacheableDependency($relationships)->withCacheableDependency($links);
+  }
+
+  /**
+   * Serializes a given field.
+   *
+   * @param mixed $field
+   *   The field to serialize.
+   * @param array $context
+   *   The normalization context.
+   * @param string $format
+   *   The serialization format.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
+   *   The normalized value.
+   */
+  protected function serializeField($field, array $context, $format) {
+    // Only content entities contain FieldItemListInterface fields. Since config
+    // entities do not have "real" fields and therefore do not have field access
+    // restrictions.
+    if ($field instanceof FieldItemListInterface) {
+      $field_access_result = $field->access('view', $context['account'], TRUE);
+      if (!$field_access_result->isAllowed()) {
+        return new CacheableOmission(CacheableMetadata::createFromObject($field_access_result));
+      }
+      $normalized_field = $this->serializer->normalize($field, $format, $context);
+      assert($normalized_field instanceof CacheableNormalization);
+      return $normalized_field->withCacheableDependency(CacheableMetadata::createFromObject($field_access_result));
+    }
+    else {
+      // Config "fields" in this case are arrays or primitives and do not need
+      // to be normalized.
+      return new CacheableNormalization(new CacheableMetadata(), $field);
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php
new file mode 100644
index 0000000000..0ca126b973
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Normalizes and UnprocessableHttpEntityException.
+ *
+ * Normalizes an UnprocessableHttpEntityException in compliance with the JSON
+ * API specification. A source pointer is added to help client applications
+ * report validation errors, for example on an Entity edit form.
+ *
+ * @see http://jsonapi.org/format/#error-objects
+ *
+ * @internal
+ */
+class UnprocessableHttpEntityExceptionNormalizer extends HttpExceptionNormalizer {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = UnprocessableHttpEntityException::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function buildErrorObjects(HttpException $exception) {
+    /* @var $exception \Drupal\jsonapi\Exception\UnprocessableHttpEntityException */
+    $errors = parent::buildErrorObjects($exception);
+    $error = $errors[0];
+    unset($error['links']);
+
+    $errors = [];
+    $violations = $exception->getViolations();
+    $entity_violations = $violations->getEntityViolations();
+    foreach ($entity_violations as $violation) {
+      /** @var \Symfony\Component\Validator\ConstraintViolation $violation */
+      $error['detail'] = 'Entity is not valid: '
+        . $violation->getMessage();
+      $error['source']['pointer'] = '/data';
+      $errors[] = $error;
+    }
+
+    $entity = $violations->getEntity();
+    foreach ($violations->getFieldNames() as $field_name) {
+      $field_violations = $violations->getByField($field_name);
+      $cardinality = $entity->get($field_name)
+        ->getFieldDefinition()
+        ->getFieldStorageDefinition()
+        ->getCardinality();
+
+      foreach ($field_violations as $violation) {
+        /** @var \Symfony\Component\Validator\ConstraintViolation $violation */
+        $error['detail'] = $violation->getPropertyPath() . ': '
+          . PlainTextOutput::renderFromHtml($violation->getMessage());
+
+        $pointer = '/data/attributes/'
+          . str_replace('.', '/', $violation->getPropertyPath());
+        if ($cardinality == 1) {
+          // Remove erroneous '/0/' index for single-value fields.
+          $pointer = str_replace("/data/attributes/$field_name/0/", "/data/attributes/$field_name/", $pointer);
+        }
+        $error['source']['pointer'] = $pointer;
+
+        $errors[] = $error;
+      }
+    }
+
+    return $errors;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/CacheableDependenciesMergerTrait.php b/core/modules/jsonapi/src/Normalizer/Value/CacheableDependenciesMergerTrait.php
new file mode 100644
index 0000000000..eb95ddf3ad
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/CacheableDependenciesMergerTrait.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableMetadata;
+
+/**
+ * Trait for \Drupal\Core\Cache\CacheableDependencyInterface::setCacheability().
+ *
+ * @internal
+ */
+trait CacheableDependenciesMergerTrait {
+
+  /**
+   * Determines the joint cacheability of all provided dependencies.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface|object[] $dependencies
+   *   The dependencies.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The cacheability of all dependencies.
+   *
+   * @see \Drupal\Core\Cache\RefinableCacheableDependencyInterface::addCacheableDependency()
+   */
+  protected static function mergeCacheableDependencies(array $dependencies) {
+    $merged_cacheability = new CacheableMetadata();
+    array_walk($dependencies, function ($dependency) use ($merged_cacheability) {
+      $merged_cacheability->addCacheableDependency($dependency);
+    });
+    return $merged_cacheability;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/CacheableNormalization.php b/core/modules/jsonapi/src/Normalizer/Value/CacheableNormalization.php
new file mode 100644
index 0000000000..cf4d00bbdf
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/CacheableNormalization.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+
+/**
+ * Use to store normalized data and its cacheability.
+ *
+ * @internal
+ */
+class CacheableNormalization implements CacheableDependencyInterface {
+
+  use CacheableDependenciesMergerTrait;
+  use CacheableDependencyTrait;
+
+  /**
+   * A normalized value.
+   *
+   * @var mixed
+   */
+  protected $normalization;
+
+  /**
+   * CacheableNormalization constructor.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cacheability metadata for the normalized data.
+   * @param array|string|int|float|bool|null $normalization
+   *   The normalized data. This value must not contain any
+   *   CacheableNormalizations.
+   */
+  public function __construct(CacheableDependencyInterface $cacheability, $normalization) {
+    assert((is_array($normalization) && static::hasNoNestedInstances($normalization)) || is_string($normalization) || is_int($normalization) || is_float($normalization) || is_bool($normalization) || is_null($normalization));
+    $this->normalization = $normalization;
+    $this->setCacheability($cacheability);
+  }
+
+  /**
+   * Gets the decorated normalization.
+   *
+   * @return array|string|int|float|bool|null
+   *   The normalization.
+   */
+  public function getNormalization() {
+    return $this->normalization;
+  }
+
+  /**
+   * Gets a new CacheableNormalization with an additional dependency.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $dependency
+   *   The new cacheable dependency.
+   *
+   * @return static
+   *   A new object based on the current value with an additional cacheable
+   *   dependency.
+   */
+  public function withCacheableDependency(CacheableDependencyInterface $dependency) {
+    return new static(static::mergeCacheableDependencies([$this, $dependency]), $this->normalization);
+  }
+
+  /**
+   * Collects an array of CacheableNormalizations into a single instance.
+   *
+   * @param \Drupal\jsonapi\Normalizer\Value\CacheableNormalization[] $cacheable_arrays
+   *   An array of CacheableNormalizations.
+   *
+   * @return static
+   *   A new CacheableNormalization. Each input value's cacheability will be
+   *   merged into the return value's cacheability. The return value's
+   *   normalization will be an array of the input's normalizations. This method
+   *   does *not* behave like array_merge() or NestedArray::mergeDeep().
+   */
+  public static function aggregate(array $cacheable_arrays) {
+    assert(Inspector::assertAllObjects($cacheable_arrays, CacheableNormalization::class));
+    return new static(
+      static::mergeCacheableDependencies($cacheable_arrays),
+      array_reduce(array_keys($cacheable_arrays), function ($merged, $key) use ($cacheable_arrays) {
+        if (!$cacheable_arrays[$key] instanceof CacheableOmission) {
+          $merged[$key] = $cacheable_arrays[$key]->getNormalization();
+        }
+        return $merged;
+      }, [])
+    );
+  }
+
+  /**
+   * Ensures that no nested values are instances of this class.
+   *
+   * @param array|\Traversable $array
+   *   The traversable object which may contain instance of this object.
+   *
+   * @return bool
+   *   Whether the given object or its children have CacheableNormalizations in
+   *   them.
+   */
+  protected static function hasNoNestedInstances($array) {
+    foreach ($array as $value) {
+      if ((is_array($value) || $value instanceof \Traversable) && !static::hasNoNestedInstances($value) || $value instanceof static) {
+        return FALSE;
+      }
+    }
+    return TRUE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/CacheableOmission.php b/core/modules/jsonapi/src/Normalizer/Value/CacheableOmission.php
new file mode 100644
index 0000000000..e19e9b2c43
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/CacheableOmission.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+
+/**
+ * Represents the cacheability associated with the omission of a value.
+ *
+ * @internal
+ */
+final class CacheableOmission extends CacheableNormalization {
+
+  /**
+   * CacheableOmission constructor.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   Cacheability related to the omission of the normalization. For example,
+   *   if a field is omitted because of an access result that varies by the
+   *   `user.permissions` cache context, we need to associate that information
+   *   with the response so that it will appear for a user *with* the
+   *   appropriate permissions for that field.
+   */
+  public function __construct(CacheableDependencyInterface $cacheability) {
+    parent::__construct($cacheability, NULL);
+  }
+
+  /**
+   * A CacheableOmission should never have its normalization retrieved.
+   */
+  public function getNormalization() {
+    throw new \LogicException('A CacheableOmission should never have its normalization retrieved.');
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/HttpExceptionNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/HttpExceptionNormalizerValue.php
new file mode 100644
index 0000000000..876d525288
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/HttpExceptionNormalizerValue.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * Helps normalize exceptions in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class HttpExceptionNormalizerValue extends CacheableNormalization {}
diff --git a/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
new file mode 100644
index 0000000000..ee07241f7c
--- /dev/null
+++ b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\jsonapi\ParamConverter;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\ParamConverter\EntityConverter;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\jsonapi\Routing\Routes;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Parameter converter for upcasting entity UUIDs to full objects.
+ *
+ * @see \Drupal\Core\ParamConverter\EntityConverter
+ *
+ * @todo Remove when https://www.drupal.org/node/2353611 lands.
+ *
+ * @internal
+ */
+class EntityUuidConverter extends EntityConverter {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function convert($value, $definition, $name, array $defaults) {
+    $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults);
+    // @see https://www.drupal.org/project/drupal/issues/2624770
+    $entity_type_manager = isset($this->entityTypeManager)
+      ? $this->entityTypeManager
+      : $this->entityManager;
+    $uuid_key = $entity_type_manager->getDefinition($entity_type_id)
+      ->getKey('uuid');
+    if ($storage = $entity_type_manager->getStorage($entity_type_id)) {
+      if (!$entities = $storage->loadByProperties([$uuid_key => $value])) {
+        return NULL;
+      }
+      $entity = reset($entities);
+      // If the entity type is translatable, ensure we return the proper
+      // translation object for the current context.
+      if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) {
+        // @see https://www.drupal.org/project/drupal/issues/2624770
+        $entity_repository = isset($this->entityRepository) ? $this->entityRepository : $this->entityManager;
+        $entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
+      }
+      return $entity;
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies($definition, $name, Route $route) {
+    return (
+      (bool) Routes::getResourceTypeNameFromParameters($route->getDefaults()) &&
+      !empty($definition['type']) && strpos($definition['type'], 'entity') === 0
+    );
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ParamConverter/ResourceTypeConverter.php b/core/modules/jsonapi/src/ParamConverter/ResourceTypeConverter.php
new file mode 100644
index 0000000000..779e6094ee
--- /dev/null
+++ b/core/modules/jsonapi/src/ParamConverter/ResourceTypeConverter.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\jsonapi\ParamConverter;
+
+use Drupal\Core\ParamConverter\ParamConverterInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Parameter converter for upcasting JSON:API resource type names to objects.
+ *
+ * @internal
+ */
+class ResourceTypeConverter implements ParamConverterInterface {
+
+  /**
+   * The route parameter type to match.
+   *
+   * @var string
+   */
+  const PARAM_TYPE_ID = 'jsonapi_resource_type';
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * ResourceTypeConverter constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->resourceTypeRepository = $resource_type_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function convert($value, $definition, $name, array $defaults) {
+    return $this->resourceTypeRepository->getByTypeName($value);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies($definition, $name, Route $route) {
+    return (!empty($definition['type']) && $definition['type'] === static::PARAM_TYPE_ID);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/EntityCondition.php b/core/modules/jsonapi/src/Query/EntityCondition.php
new file mode 100644
index 0000000000..df0b9cb179
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/EntityCondition.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * A condition object for the EntityQuery.
+ *
+ * @internal
+ */
+class EntityCondition {
+
+  /**
+   * The field key in the filter condition: filter[lorem][condition][<field>].
+   *
+   * @var string
+   */
+  const PATH_KEY = 'path';
+
+  /**
+   * The value key in the filter condition: filter[lorem][condition][<value>].
+   *
+   * @var string
+   */
+  const VALUE_KEY = 'value';
+
+  /**
+   * The operator key in the condition: filter[lorem][condition][<operator>].
+   *
+   * @var string
+   */
+  const OPERATOR_KEY = 'operator';
+
+  /**
+   * The allowed condition operators.
+   *
+   * @var string[]
+   */
+  public static $allowedOperators = [
+    '=', '<>',
+    '>', '>=', '<', '<=',
+    'STARTS_WITH', 'CONTAINS', 'ENDS_WITH',
+    'IN', 'NOT IN',
+    'BETWEEN', 'NOT BETWEEN',
+    'IS NULL', 'IS NOT NULL',
+  ];
+
+  /**
+   * The field to be evaluated.
+   *
+   * @var string
+   */
+  protected $field;
+
+  /**
+   * The condition operator.
+   *
+   * @var string
+   */
+  protected $operator;
+
+  /**
+   * The value against which the field should be evaluated.
+   *
+   * @var mixed
+   */
+  protected $value;
+
+  /**
+   * Constructs a new EntityCondition object.
+   */
+  public function __construct($field, $value, $operator = NULL) {
+    $this->field = $field;
+    $this->value = $value;
+    $this->operator = ($operator) ? $operator : '=';
+  }
+
+  /**
+   * The field to be evaluated.
+   *
+   * @return string
+   *   The field upon which to evaluate the condition.
+   */
+  public function field() {
+    return $this->field;
+  }
+
+  /**
+   * The comparison operator to use for the evaluation.
+   *
+   * For a list of allowed operators:
+   *
+   * @see \Drupal\jsonapi\Query\EntityCondition::allowedOperators
+   *
+   * @return string
+   *   The condition operator.
+   */
+  public function operator() {
+    return $this->operator;
+  }
+
+  /**
+   * The value against which the condition should be evaluated.
+   *
+   * @return mixed
+   *   The condition comparison value.
+   */
+  public function value() {
+    return $this->value;
+  }
+
+  /**
+   * Creates an EntityCondition object from a query parameter.
+   *
+   * @param mixed $parameter
+   *   The `filter[condition]` query parameter from the request.
+   *
+   * @return self
+   *   An EntityCondition object with defaults.
+   */
+  public static function createFromQueryParameter($parameter) {
+    static::validate($parameter);
+    $field = $parameter[static::PATH_KEY];
+    $value = (isset($parameter[static::VALUE_KEY])) ? $parameter[static::VALUE_KEY] : NULL;
+    $operator = (isset($parameter[static::OPERATOR_KEY])) ? $parameter[static::OPERATOR_KEY] : NULL;
+    return new static($field, $value, $operator);
+  }
+
+  /**
+   * Validates the filter has the required fields.
+   */
+  protected static function validate($parameter) {
+    $valid_key_combinations = [
+      [static::PATH_KEY, static::VALUE_KEY],
+      [static::PATH_KEY, static::OPERATOR_KEY],
+      [static::PATH_KEY, static::VALUE_KEY, static::OPERATOR_KEY],
+    ];
+
+    $given_keys = array_keys($parameter);
+    $valid_key_set = array_reduce($valid_key_combinations, function ($valid, $set) use ($given_keys) {
+      return ($valid) ? $valid : count(array_diff($set, $given_keys)) === 0;
+    }, FALSE);
+
+    $has_operator_key = isset($parameter[static::OPERATOR_KEY]);
+    $has_path_key = isset($parameter[static::PATH_KEY]);
+    $has_value_key = isset($parameter[static::VALUE_KEY]);
+
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter']);
+    if (!$valid_key_set) {
+      // Try to provide a more specific exception is a key is missing.
+      if (!$has_operator_key) {
+        if (!$has_path_key) {
+          throw new CacheableBadRequestHttpException($cacheability, "Filter parameter is missing a '" . static::PATH_KEY . "' key.");
+        }
+        if (!$has_value_key) {
+          throw new CacheableBadRequestHttpException($cacheability, "Filter parameter is missing a '" . static::VALUE_KEY . "' key.");
+        }
+      }
+
+      // Catchall exception.
+      $reason = "You must provide a valid filter condition. Check that you have set the required keys for your filter.";
+      throw new CacheableBadRequestHttpException($cacheability, $reason);
+    }
+
+    if ($has_operator_key) {
+      $operator = $parameter[static::OPERATOR_KEY];
+      if (!in_array($operator, static::$allowedOperators)) {
+        $reason = "The '" . $operator . "' operator is not allowed in a filter parameter.";
+        throw new CacheableBadRequestHttpException($cacheability, $reason);
+      }
+
+      if (in_array($operator, ['IS NULL', 'IS NOT NULL']) && $has_value_key) {
+        $reason = "Filters using the '" . $operator . "' operator should not provide a value.";
+        throw new CacheableBadRequestHttpException($cacheability, $reason);
+      }
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/EntityConditionGroup.php b/core/modules/jsonapi/src/Query/EntityConditionGroup.php
new file mode 100644
index 0000000000..c90835ae7d
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/EntityConditionGroup.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * A condition group for the EntityQuery.
+ *
+ * @internal
+ */
+class EntityConditionGroup {
+
+  /**
+   * The AND conjunction value.
+   *
+   * @var array
+   */
+  protected static $allowedConjunctions = ['AND', 'OR'];
+
+  /**
+   * The conjunction.
+   *
+   * @var string
+   */
+  protected $conjunction;
+
+  /**
+   * The members of the condition group.
+   *
+   * @var \Drupal\jsonapi\Query\EntityCondition[]
+   */
+  protected $members;
+
+  /**
+   * Constructs a new condition group object.
+   *
+   * @param string $conjunction
+   *   The group conjunction to use.
+   * @param array $members
+   *   (optional) The group conjunction to use.
+   */
+  public function __construct($conjunction, array $members = []) {
+    if (!in_array($conjunction, self::$allowedConjunctions)) {
+      throw new \InvalidArgumentException('Allowed conjunctions: AND, OR.');
+    }
+    $this->conjunction = $conjunction;
+    $this->members = $members;
+  }
+
+  /**
+   * The condition group conjunction.
+   *
+   * @return string
+   *   The condition group conjunction.
+   */
+  public function conjunction() {
+    return $this->conjunction;
+  }
+
+  /**
+   * The members which belong to the the condition group.
+   *
+   * @return \Drupal\jsonapi\Query\EntityCondition[]
+   *   The member conditions of this condition group.
+   */
+  public function members() {
+    return $this->members;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/Filter.php b/core/modules/jsonapi/src/Query/Filter.php
new file mode 100644
index 0000000000..3fed18b44c
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/Filter.php
@@ -0,0 +1,298 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Gathers information about the filter parameter.
+ *
+ * @internal
+ */
+class Filter {
+
+  /**
+   * The JSON:API filter key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'filter';
+
+  /**
+   * The key for the implicit root group.
+   */
+  const ROOT_ID = '@root';
+
+  /**
+   * Key in the filter[<key>] parameter for conditions.
+   *
+   * @var string
+   */
+  const CONDITION_KEY = 'condition';
+
+  /**
+   * Key in the filter[<key>] parameter for groups.
+   *
+   * @var string
+   */
+  const GROUP_KEY = 'group';
+
+  /**
+   * Key in the filter[<id>][<key>] parameter for group membership.
+   *
+   * @var string
+   */
+  const MEMBER_KEY = 'memberOf';
+
+  /**
+   * The root condition group.
+   *
+   * @var string
+   */
+  protected $root;
+
+  /**
+   * Constructs a new Filter object.
+   *
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $root
+   *   An entity condition group which can be applied to an entity query.
+   */
+  public function __construct(EntityConditionGroup $root) {
+    $this->root = $root;
+  }
+
+  /**
+   * Gets the root condition group.
+   */
+  public function root() {
+    return $this->root;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query for which the condition should be constructed.
+   *
+   * @return \Drupal\Core\Entity\Query\ConditionInterface
+   *   The compiled entity query condition.
+   */
+  public function queryCondition(QueryInterface $query) {
+    $condition = $this->buildGroup($query, $this->root());
+    return $condition;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface $query
+   *   The query to which the filter should be applied.
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $condition_group
+   *   The condition group to build.
+   *
+   * @return \Drupal\Core\Entity\Query\ConditionInterface
+   *   The query with the filter applied.
+   */
+  protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) {
+    // Create a condition group using the original query.
+    switch ($condition_group->conjunction()) {
+      case 'AND':
+        $group = $query->andConditionGroup();
+        break;
+
+      case 'OR':
+        $group = $query->orConditionGroup();
+        break;
+    }
+
+    // Get all children of the group.
+    $members = $condition_group->members();
+
+    foreach ($members as $member) {
+      // If the child is simply a condition, add it to the new group.
+      if ($member instanceof EntityCondition) {
+        if ($member->operator() == 'IS NULL') {
+          $group->notExists($member->field());
+        }
+        elseif ($member->operator() == 'IS NOT NULL') {
+          $group->exists($member->field());
+        }
+        else {
+          $group->condition($member->field(), $member->value(), $member->operator());
+        }
+      }
+      // If the child is a group, then recursively construct a sub group.
+      elseif ($member instanceof EntityConditionGroup) {
+        // Add the subgroup to this new group.
+        $subgroup = $this->buildGroup($query, $member);
+        $group->condition($subgroup);
+      }
+    }
+
+    // Return the constructed group so that it can be added to the query.
+    return $group;
+  }
+
+  /**
+   * Creates a Sort object from a query parameter.
+   *
+   * @param mixed $parameter
+   *   The `filter` query parameter from the Symfony request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type.
+   * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
+   *   The JSON:API field resolver.
+   *
+   * @return self
+   *   A Sort object with defaults.
+   */
+  public static function createFromQueryParameter($parameter, ResourceType $resource_type, FieldResolver $field_resolver) {
+    $expanded = static::expand($parameter);
+    foreach ($expanded as &$filter_item) {
+      if (isset($filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY])) {
+        $unresolved = $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY];
+        $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $unresolved);
+      }
+    }
+    return new static(static::buildEntityConditionGroup($expanded));
+  }
+
+  /**
+   * Expands any filter parameters using shorthand notation.
+   *
+   * @param array $original
+   *   The unexpanded filter data.
+   *
+   * @return array
+   *   The expanded filter data.
+   */
+  protected static function expand(array $original) {
+    $expanded = [];
+    foreach ($original as $key => $item) {
+      // Allow extreme shorthand filters, f.e. `?filter[promote]=1`.
+      if (!is_array($item)) {
+        $item = [
+          EntityCondition::VALUE_KEY => $item,
+        ];
+      }
+
+      // Throw an exception if the query uses the reserved filter id for the
+      // root group.
+      if ($key == static::ROOT_ID) {
+        $msg = sprintf("'%s' is a reserved filter id.", static::ROOT_ID);
+        throw new \UnexpectedValueException($msg);
+      }
+
+      // Add a memberOf key to all items.
+      if (isset($item[static::CONDITION_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::CONDITION_KEY][static::MEMBER_KEY];
+        unset($item[static::CONDITION_KEY][static::MEMBER_KEY]);
+      }
+      elseif (isset($item[static::GROUP_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::GROUP_KEY][static::MEMBER_KEY];
+        unset($item[static::GROUP_KEY][static::MEMBER_KEY]);
+      }
+      else {
+        $item[static::MEMBER_KEY] = static::ROOT_ID;
+      }
+
+      // Add the filter id to all items.
+      $item['id'] = $key;
+
+      // Expands shorthand filters.
+      $expanded[$key] = static::expandItem($key, $item);
+    }
+
+    return $expanded;
+  }
+
+  /**
+   * Expands a filter item in case a shortcut was used.
+   *
+   * Possible cases for the conditions:
+   *   1. filter[uuid][value]=1234.
+   *   2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234.
+   *   3. filter[uuid][condition][value]=1234.
+   *   4. filter[uuid][value]=1234&filter[uuid][group]=my_group.
+   *
+   * @param string $filter_index
+   *   The index.
+   * @param array $filter_item
+   *   The raw filter item.
+   *
+   * @return array
+   *   The expanded filter item.
+   */
+  protected static function expandItem($filter_index, array $filter_item) {
+    if (isset($filter_item[EntityCondition::VALUE_KEY])) {
+      if (!isset($filter_item[EntityCondition::PATH_KEY])) {
+        $filter_item[EntityCondition::PATH_KEY] = $filter_index;
+      }
+
+      $filter_item = [
+        static::CONDITION_KEY => $filter_item,
+        static::MEMBER_KEY => $filter_item[static::MEMBER_KEY],
+      ];
+    }
+
+    if (!isset($filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY] = '=';
+    }
+
+    return $filter_item;
+  }
+
+  /**
+   * Denormalizes the given filter items into a single EntityConditionGroup.
+   *
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   A root group containing all the denormalized conditions and groups.
+   */
+  protected static function buildEntityConditionGroup(array $items) {
+    $root = [
+      'id' => static::ROOT_ID,
+      static::GROUP_KEY => ['conjunction' => 'AND'],
+    ];
+    return static::buildTree($root, $items);
+  }
+
+  /**
+   * Organizes the flat, normalized filter items into a tree structure.
+   *
+   * @param array $root
+   *   The root of the tree to build.
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   The entity condition group
+   */
+  protected static function buildTree(array $root, array $items) {
+    $id = $root['id'];
+
+    // Recursively build a tree of denormalized conditions and condition groups.
+    $members = [];
+    foreach ($items as $item) {
+      if ($item[static::MEMBER_KEY] == $id) {
+        if (isset($item[static::GROUP_KEY])) {
+          array_push($members, static::buildTree($item, $items));
+        }
+        elseif (isset($item[static::CONDITION_KEY])) {
+          $condition = EntityCondition::createFromQueryParameter($item[static::CONDITION_KEY]);
+          array_push($members, $condition);
+        }
+      }
+    }
+
+    $root[static::GROUP_KEY]['members'] = $members;
+
+    // Denormalize the root into a condition group.
+    return new EntityConditionGroup($root[static::GROUP_KEY]['conjunction'], $root[static::GROUP_KEY]['members']);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/OffsetPage.php b/core/modules/jsonapi/src/Query/OffsetPage.php
new file mode 100644
index 0000000000..44f5e07fff
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/OffsetPage.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * Value object for containing the requested offset and page parameters.
+ *
+ * @internal
+ */
+class OffsetPage {
+
+  /**
+   * The JSON:API pagination key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'page';
+
+  /**
+   * The offset key in the page parameter: page[offset].
+   *
+   * @var string
+   */
+  const OFFSET_KEY = 'offset';
+
+  /**
+   * The size key in the page parameter: page[limit].
+   *
+   * @var string
+   */
+  const SIZE_KEY = 'limit';
+
+  /**
+   * Default offset.
+   *
+   * @var int
+   */
+  const DEFAULT_OFFSET = 0;
+
+  /**
+   * Max size.
+   *
+   * @var int
+   */
+  const SIZE_MAX = 50;
+
+  /**
+   * The offset for the query.
+   *
+   * @var int
+   */
+  protected $offset;
+
+  /**
+   * The size of the query.
+   *
+   * @var int
+   */
+  protected $size;
+
+  /**
+   * Instantiates an OffsetPage object.
+   *
+   * @param int $offset
+   *   The query offset.
+   * @param int $size
+   *   The query size limit.
+   */
+  public function __construct($offset, $size) {
+    $this->offset = $offset;
+    $this->size = $size;
+  }
+
+  /**
+   * Returns the current offset.
+   *
+   * @return int
+   *   The query offset.
+   */
+  public function getOffset() {
+    return $this->offset;
+  }
+
+  /**
+   * Returns the page size.
+   *
+   * @return int
+   *   The requested size of the query result.
+   */
+  public function getSize() {
+    return $this->size;
+  }
+
+  /**
+   * Creates an OffsetPage object from a query parameter.
+   *
+   * @param mixed $parameter
+   *   The `page` query parameter from the Symfony request object.
+   *
+   * @return \Drupal\jsonapi\Query\OffsetPage
+   *   An OffsetPage object with defaults.
+   */
+  public static function createFromQueryParameter($parameter) {
+    if (!is_array($parameter)) {
+      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']);
+      throw new CacheableBadRequestHttpException($cacheability, 'The page parameter needs to be an array.');
+    }
+
+    $expanded = $parameter + [
+      static::OFFSET_KEY => static::DEFAULT_OFFSET,
+      static::SIZE_KEY => static::SIZE_MAX,
+    ];
+
+    if ($expanded[static::SIZE_KEY] > static::SIZE_MAX) {
+      $expanded[static::SIZE_KEY] = static::SIZE_MAX;
+    }
+
+    return new static($expanded[static::OFFSET_KEY], $expanded[static::SIZE_KEY]);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/Sort.php b/core/modules/jsonapi/src/Query/Sort.php
new file mode 100644
index 0000000000..a6cab21bd0
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/Sort.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * Gathers information about the sort parameter.
+ *
+ * @internal
+ */
+class Sort {
+
+  /**
+   * The JSON:API sort key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'sort';
+
+  /**
+   * The field key in the sort parameter: sort[lorem][<field>].
+   *
+   * @var string
+   */
+  const PATH_KEY = 'path';
+
+  /**
+   * The direction key in the sort parameter: sort[lorem][<direction>].
+   *
+   * @var string
+   */
+  const DIRECTION_KEY = 'direction';
+
+  /**
+   * The langcode key in the sort parameter: sort[lorem][<langcode>].
+   *
+   * @var string
+   */
+  const LANGUAGE_KEY = 'langcode';
+
+  /**
+   * The fields on which to sort.
+   *
+   * @var string
+   */
+  protected $fields;
+
+  /**
+   * Constructs a new Sort object.
+   *
+   * Takes an array of sort fields. Example:
+   *   [
+   *     [
+   *       'path' => 'changed',
+   *       'direction' => 'DESC',
+   *     ],
+   *     [
+   *       'path' => 'title',
+   *       'direction' => 'ASC',
+   *       'langcode' => 'en-US',
+   *     ],
+   *   ]
+   *
+   * @param array $fields
+   *   The the entity query sort fields.
+   */
+  public function __construct(array $fields) {
+    $this->fields = $fields;
+  }
+
+  /**
+   * Gets the root condition group.
+   */
+  public function fields() {
+    return $this->fields;
+  }
+
+  /**
+   * Creates a Sort object from a query parameter.
+   *
+   * @param mixed $parameter
+   *   The `sort` query parameter from the Symfony request object.
+   *
+   * @return self
+   *   A Sort object with defaults.
+   */
+  public static function createFromQueryParameter($parameter) {
+    if (empty($parameter)) {
+      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:sort']);
+      throw new CacheableBadRequestHttpException($cacheability, 'You need to provide a value for the sort parameter.');
+    }
+
+    // Expand a JSON:API compliant sort into a more expressive sort parameter.
+    if (is_string($parameter)) {
+      $parameter = static::expandFieldString($parameter);
+    }
+
+    // Expand any defaults into the sort array.
+    $expanded = [];
+    foreach ($parameter as $sort_index => $sort_item) {
+      $expanded[$sort_index] = static::expandItem($sort_item);
+    }
+
+    return new static($expanded);
+  }
+
+  /**
+   * Expands a simple string sort into a more expressive sort that we can use.
+   *
+   * @param string $fields
+   *   The comma separated list of fields to expand into an array.
+   *
+   * @return array
+   *   The expanded sort.
+   */
+  protected static function expandFieldString($fields) {
+    return array_map(function ($field) {
+      $sort = [];
+
+      if ($field[0] == '-') {
+        $sort[static::DIRECTION_KEY] = 'DESC';
+        $sort[static::PATH_KEY] = substr($field, 1);
+      }
+      else {
+        $sort[static::DIRECTION_KEY] = 'ASC';
+        $sort[static::PATH_KEY] = $field;
+      }
+
+      return $sort;
+    }, explode(',', $fields));
+  }
+
+  /**
+   * Expands a sort item in case a shortcut was used.
+   *
+   * @param array $sort_item
+   *   The raw sort item.
+   *
+   * @return array
+   *   The expanded sort item.
+   */
+  protected static function expandItem(array $sort_item) {
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:sort']);
+    $defaults = [
+      static::DIRECTION_KEY => 'ASC',
+      static::LANGUAGE_KEY => NULL,
+    ];
+
+    if (!isset($sort_item[static::PATH_KEY])) {
+      throw new CacheableBadRequestHttpException($cacheability, 'You need to provide a field name for the sort parameter.');
+    }
+
+    $expected_keys = [
+      static::PATH_KEY,
+      static::DIRECTION_KEY,
+      static::LANGUAGE_KEY,
+    ];
+
+    $expanded = array_merge($defaults, $sort_item);
+
+    // Verify correct sort keys.
+    if (count(array_diff($expected_keys, array_keys($expanded))) > 0) {
+      throw new CacheableBadRequestHttpException($cacheability, 'You have provided an invalid set of sort keys.');
+    }
+
+    return $expanded;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceResponse.php b/core/modules/jsonapi/src/ResourceResponse.php
new file mode 100644
index 0000000000..f3a1bf11ad
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceResponse.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Cache\CacheableResponseTrait;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Contains data for serialization before sending the response.
+ *
+ * We do not want to abuse the $content property on the Response class to store
+ * our response data. $content implies that the provided data must either be a
+ * string or an object with a __toString() method, which is not a requirement
+ * for data used here.
+ *
+ * @see \Drupal\rest\ModifiedResourceResponse
+ *
+ * @internal
+ */
+class ResourceResponse extends Response implements CacheableResponseInterface {
+
+  use CacheableResponseTrait;
+
+  /**
+   * Response data that should be serialized.
+   *
+   * @var mixed
+   */
+  protected $responseData;
+
+  /**
+   * Constructor for ResourceResponse objects.
+   *
+   * @param mixed $data
+   *   Response data that should be serialized.
+   * @param int $status
+   *   The response status code.
+   * @param array $headers
+   *   An array of response headers.
+   */
+  public function __construct($data = NULL, $status = 200, array $headers = []) {
+    $this->responseData = $data;
+    parent::__construct('', $status, $headers);
+  }
+
+  /**
+   * Returns response data that should be serialized.
+   *
+   * @return mixed
+   *   Response data that should be serialized.
+   */
+  public function getResponseData() {
+    return $this->responseData;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceType.php b/core/modules/jsonapi/src/ResourceType/ResourceType.php
new file mode 100644
index 0000000000..003d1a52d0
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceType.php
@@ -0,0 +1,388 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Value object containing all metadata for a JSON:API resource type.
+ *
+ * Used to generate routes (collection, individual, etcetera), generate
+ * relationship links, and so on.
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ *
+ * @internal
+ */
+class ResourceType {
+
+  /**
+   * The entity type ID.
+   *
+   * @var string
+   */
+  protected $entityTypeId;
+
+  /**
+   * The bundle ID.
+   *
+   * @var string
+   */
+  protected $bundle;
+
+  /**
+   * The type name.
+   *
+   * @var string
+   */
+  protected $typeName;
+
+  /**
+   * The class to which a payload converts to.
+   *
+   * @var string
+   */
+  protected $deserializationTargetClass;
+
+  /**
+   * Whether this resource type is internal.
+   *
+   * @var bool
+   */
+  protected $internal;
+
+  /**
+   * Whether this resource type's resources are locatable.
+   *
+   * @var bool
+   */
+  protected $isLocatable;
+
+  /**
+   * Whether this resource type's resources are mutable.
+   *
+   * @var bool
+   */
+  protected $isMutable;
+
+  /**
+   * Whether this resource type's resources are versionable.
+   *
+   * @var bool
+   */
+  protected $isVersionable;
+
+  /**
+   * The list of fields on the underlying entity type + bundle.
+   *
+   * @var string[]
+   */
+  protected $fields;
+
+  /**
+   * The list of disabled fields. Disabled by default: uuid, id, type.
+   *
+   * @var string[]
+   *
+   * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+   */
+  protected $disabledFields;
+
+  /**
+   * The mapping for field aliases: keys=internal names, values=public names.
+   *
+   * @var string[]
+   */
+  protected $fieldMapping;
+
+  /**
+   * The inverse of $fieldMapping.
+   *
+   * @var string[]
+   */
+  protected $invertedFieldMapping;
+
+  /**
+   * Gets the entity type ID.
+   *
+   * @return string
+   *   The entity type ID.
+   *
+   * @see \Drupal\Core\Entity\EntityInterface::getEntityTypeId
+   */
+  public function getEntityTypeId() {
+    return $this->entityTypeId;
+  }
+
+  /**
+   * Gets the type name.
+   *
+   * @return string
+   *   The type name.
+   */
+  public function getTypeName() {
+    return $this->typeName;
+  }
+
+  /**
+   * Gets the bundle.
+   *
+   * @return string
+   *   The bundle of the entity. Defaults to the entity type ID if the entity
+   *   type does not make use of different bundles.
+   *
+   * @see \Drupal\Core\Entity\EntityInterface::bundle
+   */
+  public function getBundle() {
+    return $this->bundle;
+  }
+
+  /**
+   * Gets the deserialization target class.
+   *
+   * @return string
+   *   The deserialization target class.
+   */
+  public function getDeserializationTargetClass() {
+    return $this->deserializationTargetClass;
+  }
+
+  /**
+   * Translates the entity field name to the public field name.
+   *
+   * This is only here so we can allow polymorphic implementations to take a
+   * greater control on the field names.
+   *
+   * @return string
+   *   The public field name.
+   */
+  public function getPublicName($field_name) {
+    // By default the entity field name is the public field name.
+    return isset($this->fieldMapping[$field_name])
+      ? $this->fieldMapping[$field_name]
+      : $field_name;
+  }
+
+  /**
+   * Translates the public field name to the entity field name.
+   *
+   * This is only here so we can allow polymorphic implementations to take a
+   * greater control on the field names.
+   *
+   * @return string
+   *   The internal field name as defined in the entity.
+   */
+  public function getInternalName($field_name) {
+    // By default the entity field name is the public field name.
+    return isset($this->invertedFieldMapping[$field_name])
+      ? $this->invertedFieldMapping[$field_name]
+      : $field_name;
+  }
+
+  /**
+   * Checks if the field exists.
+   *
+   * Note: a minority of config entity types which do not define a
+   * `config_export` in their entity type annotation will not have their fields
+   * represented here because it is impossible to determine them without an
+   * instance of config available.
+   *
+   * @todo Refactor this in Drupal 9, because thanks to https://www.drupal.org/project/drupal/issues/2949021, `config_export` will be guaranteed to exist, and this won't need an instance anymore.
+   *
+   * @param string $field_name
+   *   The internal field name.
+   *
+   * @return bool
+   *   TRUE if the field is known to exist on the resource type; FALSE
+   *   otherwise.
+   */
+  public function hasField($field_name) {
+    return in_array($field_name, $this->fields, TRUE);
+  }
+
+  /**
+   * Checks if a field is enabled or not.
+   *
+   * This is only here so we can allow polymorphic implementations to take a
+   * greater control on the data model.
+   *
+   * @param string $field_name
+   *   The internal field name.
+   *
+   * @return bool
+   *   TRUE if the field exists and is enabled and should be considered as part
+   *   of the data model. FALSE otherwise.
+   */
+  public function isFieldEnabled($field_name) {
+    return $this->hasField($field_name) && !in_array($field_name, $this->disabledFields, TRUE);
+  }
+
+  /**
+   * Determine whether to include a collection count.
+   *
+   * @return bool
+   *   Whether to include a collection count.
+   */
+  public function includeCount() {
+    // By default, do not return counts in collection queries.
+    return FALSE;
+  }
+
+  /**
+   * Whether this resource type is internal.
+   *
+   * This must not be used as an access control mechanism.
+   *
+   * Internal resource types are not available via the HTTP API. They have no
+   * routes and cannot be used for filtering or sorting. They cannot be included
+   * in the response using the `include` query parameter.
+   *
+   * However, relationship fields on public resources *will include* a resource
+   * identifier for the referenced internal resource.
+   *
+   * This method exists to remove data that should not logically be exposed by
+   * the HTTP API. For example, read-only data from an internal resource might
+   * be embedded in a public resource using computed fields. Therefore,
+   * including the internal resource as a relationship with distinct routes
+   * might uneccesarilly expose internal implementation details.
+   *
+   * @return bool
+   *   TRUE if the resource type is internal. FALSE otherwise.
+   */
+  public function isInternal() {
+    return $this->internal;
+  }
+
+  /**
+   * Whether resources of this resource type are locatable.
+   *
+   * A resource type may for example not be locatable when it is not stored.
+   *
+   * @return bool
+   *   TRUE if the resource type's resources are locatable. FALSE otherwise.
+   */
+  public function isLocatable() {
+    return $this->isLocatable;
+  }
+
+  /**
+   * Whether resources of this resource type are mutable.
+   *
+   * Indicates that resources of this type may not be created, updated or
+   * deleted (POST, PATCH or DELETE, respectively).
+   *
+   * @return bool
+   *   TRUE if the resource type's resources are mutable. FALSE otherwise.
+   */
+  public function isMutable() {
+    return $this->isMutable;
+  }
+
+  /**
+   * Whether resources of this resource type are versionable.
+   *
+   * @return bool
+   *   TRUE if the resource type's resources are versionable. FALSE otherwise.
+   */
+  public function isVersionable() {
+    return $this->isVersionable;
+  }
+
+  /**
+   * Instantiates a ResourceType object.
+   *
+   * @param string $entity_type_id
+   *   An entity type ID.
+   * @param string $bundle
+   *   A bundle.
+   * @param string $deserialization_target_class
+   *   The deserialization target class.
+   * @param bool $internal
+   *   (optional) Whether the resource type should be internal.
+   * @param bool $is_locatable
+   *   (optional) Whether the resource type is locatable.
+   * @param bool $is_mutable
+   *   (optional) Whether the resource type is mutable.
+   * @param bool $is_versionable
+   *   (optional) Whether the resource type is versionable.
+   * @param array $field_mapping
+   *   (optional) The field mapping to use.
+   */
+  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $field_mapping = []) {
+    $this->entityTypeId = $entity_type_id;
+    $this->bundle = $bundle;
+    $this->deserializationTargetClass = $deserialization_target_class;
+    $this->internal = $internal;
+    $this->isLocatable = $is_locatable;
+    $this->isMutable = $is_mutable;
+    $this->isVersionable = $is_versionable;
+
+    $this->typeName = $this->bundle === '?'
+      ? 'unknown'
+      : sprintf('%s--%s', $this->entityTypeId, $this->bundle);
+
+    $this->fields = array_keys($field_mapping);
+    $this->disabledFields = array_keys(array_filter($field_mapping, function ($v) {
+      return $v === FALSE;
+    }));
+    $this->fieldMapping = array_filter($field_mapping, 'is_string');
+    $this->invertedFieldMapping = array_flip($this->fieldMapping);
+  }
+
+  /**
+   * Sets the relatable resource types.
+   *
+   * @param array $relatable_resource_types
+   *   The resource types with which this resource type may have a relationship.
+   *   The array should be a multi-dimensional array keyed by public field name
+   *   whose values are an array of resource types. There may be duplicate
+   *   across resource types across fields, but not within a field.
+   */
+  public function setRelatableResourceTypes(array $relatable_resource_types) {
+    $this->relatableResourceTypes = $relatable_resource_types;
+  }
+
+  /**
+   * Get all resource types with which this type may have a relationship.
+   *
+   * @return array
+   *   The relatable resource types, keyed by relationship field names.
+   *
+   * @see self::setRelatableResourceTypes()
+   */
+  public function getRelatableResourceTypes() {
+    if (!isset($this->relatableResourceTypes)) {
+      throw new \LogicException("setRelatableResourceTypes() must be called before getting relatable resource types.");
+    }
+    return $this->relatableResourceTypes;
+  }
+
+  /**
+   * Get all resource types with which the given field may have a relationship.
+   *
+   * @param string $field_name
+   *   The public field name.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The relatable JSON:API resource types.
+   *
+   * @see self::getRelatableResourceTypes()
+   */
+  public function getRelatableResourceTypesByField($field_name) {
+    $relatable_resource_types = $this->getRelatableResourceTypes();
+    return isset($relatable_resource_types[$field_name]) ?
+      $relatable_resource_types[$field_name] :
+      [];
+  }
+
+  /**
+   * Get the resource path.
+   *
+   * @return string
+   *   The path to access this resource type. Default: /entity_type_id/bundle.
+   *
+   * @see jsonapi.base_path
+   */
+  public function getPath() {
+    return sprintf('/%s/%s', $this->getEntityTypeId(), $this->getBundle());
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
new file mode 100644
index 0000000000..d7dff530c8
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
@@ -0,0 +1,433 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\ContentEntityNullStorage;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\TypedData\DataReferenceTargetDefinition;
+use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
+
+/**
+ * Provides a repository of all JSON:API resource types.
+ *
+ * Contains the complete set of ResourceType value objects, which are auto-
+ * generated based on the Entity Type Manager and Entity Type Bundle Info: one
+ * JSON:API resource type per entity type bundle. So, for example:
+ * - node--article
+ * - node--page
+ * - node--…
+ * - user--user
+ * - …
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceType
+ *
+ * @internal
+ */
+class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The bundle manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
+   */
+  protected $entityTypeBundleInfo;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $entityFieldManager;
+
+  /**
+   * The static cache backend.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $staticCache;
+
+  /**
+   * Instance data cache.
+   *
+   * @var array
+   */
+  protected $cache = [];
+
+  /**
+   * Instantiates a ResourceTypeRepository object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_bundle_info
+   *   The entity type bundle info service.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
+   *   The entity field manager.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $static_cache
+   *   The static cache backend.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_bundle_info, EntityFieldManagerInterface $entity_field_manager, CacheBackendInterface $static_cache) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityTypeBundleInfo = $entity_bundle_info;
+    $this->entityFieldManager = $entity_field_manager;
+    $this->staticCache = $static_cache;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function all() {
+    $cached = $this->staticCache->get('jsonapi.resource_types', FALSE);
+    if ($cached === FALSE) {
+      $resource_types = [];
+      foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
+        $resource_types = array_merge($resource_types, array_map(function ($bundle) use ($entity_type) {
+          return $this->createResourceType($entity_type, $bundle);
+        }, array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type->id()))));
+      }
+      foreach ($resource_types as $resource_type) {
+        $relatable_resource_types = $this->calculateRelatableResourceTypes($resource_type, $resource_types);
+        $resource_type->setRelatableResourceTypes($relatable_resource_types);
+      }
+      $this->staticCache->set('jsonapi.resource_types', $resource_types, Cache::PERMANENT, ['jsonapi_resource_types']);
+    }
+    return $cached ? $cached->data : $resource_types;
+  }
+
+  /**
+   * Creates a ResourceType value object for the given entity type + bundle.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to create a JSON:API resource type for.
+   * @param string $bundle
+   *   The entity type bundle to create a JSON:API resource type for.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   A JSON:API resource type.
+   */
+  protected function createResourceType(EntityTypeInterface $entity_type, $bundle) {
+    $raw_fields = $this->getAllFieldNames($entity_type, $bundle);
+    return new ResourceType(
+      $entity_type->id(),
+      $bundle,
+      $entity_type->getClass(),
+      $entity_type->isInternal(),
+      static::isLocatableResourceType($entity_type, $bundle),
+      static::isMutableResourceType($entity_type, $bundle),
+      static::isVersionableResourceType($entity_type),
+      static::getFieldMapping($raw_fields, $entity_type, $bundle)
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($entity_type_id, $bundle) {
+    assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
+    if (empty($entity_type_id)) {
+      throw new PreconditionFailedHttpException('Server error. The current route is malformed.');
+    }
+
+    $cid = "jsonapi:resource_type:$entity_type_id:$bundle";
+    if (!array_key_exists($cid, $this->cache)) {
+      $result = NULL;
+      foreach ($this->all() as $resource) {
+        if ($resource->getEntityTypeId() == $entity_type_id && $resource->getBundle() == $bundle) {
+          $result = $resource;
+          break;
+        }
+      }
+      $this->cache[$cid] = $result;
+    }
+
+    return $this->cache[$cid];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getByTypeName($type_name) {
+    foreach ($this->all() as $resource) {
+      if ($resource->getTypeName() == $type_name) {
+        return $resource;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets the field mapping for the given field names and entity type + bundle.
+   *
+   * @param string[] $field_names
+   *   All field names on a bundle of the given entity type.
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to get the field mapping.
+   * @param string $bundle
+   *   The bundle to assess.
+   *
+   * @return array
+   *   An array with:
+   *   - keys are (real/internal) field names
+   *   - values are either FALSE (indicating the field is not exposed despite
+   *     not being internal), TRUE (indicating the field should be exposed under
+   *     its internal name) or a string (indicating the field should not be
+   *     exposed using its internal name, but the name specified in the string)
+   */
+  protected static function getFieldMapping(array $field_names, EntityTypeInterface $entity_type, $bundle) {
+    assert(Inspector::assertAllStrings($field_names));
+    assert($entity_type instanceof ContentEntityTypeInterface || $entity_type instanceof ConfigEntityTypeInterface);
+    assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
+
+    $mapping = [];
+
+    // JSON:API resource identifier objects are sufficient to identify
+    // entities. By exposing all fields as attributes, we expose unwanted,
+    // confusing or duplicate information:
+    // - exposing an entity's ID (which is not a UUID) is bad, but it's
+    //   necessary for certain Drupal-coupled clients, so we alias it by
+    //   prefixing it with `drupal_internal__`.
+    // - exposing an entity's UUID as an attribute is useless (it's already part
+    //   of the mandatory "id" attribute in JSON:API), so we disable it in most
+    //   cases.
+    // - exposing its revision ID as an attribute will compete with any profile
+    //   defined meta members used for resource object versioning.
+    // @see http://jsonapi.org/format/#document-resource-identifier-objects
+    $id_field_name = $entity_type->getKey('id');
+    $uuid_field_name = $entity_type->getKey('uuid');
+    if ($uuid_field_name !== 'id') {
+      $mapping[$uuid_field_name] = FALSE;
+    }
+    $mapping[$id_field_name] = "drupal_internal__$id_field_name";
+    if ($entity_type->isRevisionable() && ($revision_id_field_name = $entity_type->getKey('revision'))) {
+      $mapping[$revision_id_field_name] = "drupal_internal__$revision_id_field_name";
+    }
+    if ($entity_type instanceof ConfigEntityTypeInterface) {
+      // The '_core' key is reserved by Drupal core to handle complex edge cases
+      // correctly. Data in the '_core' key is irrelevant to clients reading
+      // configuration, and is not allowed to be set by clients writing
+      // configuration: it is for Drupal core only, and managed by Drupal core.
+      // @see https://www.drupal.org/node/2653358
+      $mapping['_core'] = FALSE;
+    }
+
+    // For all other fields,  use their internal field name also as their public
+    // field name.  Unless they're called "id" or "type": those names are
+    // reserved by the JSON:API spec.
+    // @see http://jsonapi.org/format/#document-resource-object-fields
+    foreach (array_diff($field_names, array_keys($mapping)) as $field_name) {
+      if ($field_name === 'id' || $field_name === 'type') {
+        $alias = $entity_type->id() . '_' . $field_name;
+        if (isset($field_name[$alias])) {
+          throw new \LogicException('The generated alias conflicts with an existing field. Please report this in the JSON:API issue queue!');
+        }
+        $mapping[$field_name] = $alias;
+        continue;
+      }
+
+      // The default, which applies to most fields: expose as-is.
+      $mapping[$field_name] = TRUE;
+    }
+
+    return $mapping;
+  }
+
+  /**
+   * Gets all field names for a given entity type and bundle.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to get all field names.
+   * @param string $bundle
+   *   The bundle for which to get all field names.
+   *
+   * @return string[]
+   *   All field names.
+   */
+  protected function getAllFieldNames(EntityTypeInterface $entity_type, $bundle) {
+    if ($entity_type instanceof ContentEntityTypeInterface) {
+      $field_definitions = $this->entityFieldManager->getFieldDefinitions(
+        $entity_type->id(),
+        $bundle
+      );
+      return array_keys($field_definitions);
+    }
+    elseif ($entity_type instanceof ConfigEntityTypeInterface) {
+      // @todo Uncomment the first line, remove everything else once https://www.drupal.org/project/drupal/issues/2483407 lands.
+      // return array_keys($entity_type->getPropertiesToExport());
+      $export_properties = $entity_type->getPropertiesToExport();
+      if ($export_properties !== NULL) {
+        return array_keys($export_properties);
+      }
+      else {
+        return ['id', 'type', 'uuid', '_core'];
+      }
+    }
+    else {
+      throw new \LogicException("Only content and config entity types are supported.");
+    }
+  }
+
+  /**
+   * Whether an entity type + bundle maps to a mutable resource type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to assess.
+   * @param string $bundle
+   *   The bundle to assess.
+   *
+   * @return bool
+   *   TRUE if the entity type is mutable, FALSE otherwise.
+   */
+  protected static function isMutableResourceType(EntityTypeInterface $entity_type, $bundle) {
+    assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
+    return !$entity_type instanceof ConfigEntityTypeInterface;
+  }
+
+  /**
+   * Whether an entity type + bundle maps to a locatable resource type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to assess.
+   * @param string $bundle
+   *   The bundle to assess.
+   *
+   * @return bool
+   *   TRUE if the entity type is locatable, FALSE otherwise.
+   */
+  protected static function isLocatableResourceType(EntityTypeInterface $entity_type, $bundle) {
+    assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
+    return $entity_type->getStorageClass() !== ContentEntityNullStorage::class;
+  }
+
+  /**
+   * Whether an entity type is a versionable resource type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to assess.
+   *
+   * @return bool
+   *   TRUE if the entity type is versionable, FALSE otherwise.
+   */
+  protected static function isVersionableResourceType(EntityTypeInterface $entity_type) {
+    // @todo: remove the following line and uncomment the next one when revisions have standardized access control. For now, it is unsafe to support all revisionable entity types.
+    return in_array($entity_type->id(), ['node', 'media']);
+    /* return $entity_type->isRevisionable(); */
+  }
+
+  /**
+   * Calculates relatable JSON:API resource types for a given resource type.
+   *
+   * This method has no affect after being called once.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type repository.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   A list of JSON:API resource types.
+   *
+   * @return array
+   *   The relatable JSON:API resource types, keyed by field name.
+   */
+  protected function calculateRelatableResourceTypes(ResourceType $resource_type, array $resource_types) {
+    // For now, only fieldable entity types may contain relationships.
+    $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
+    if ($entity_type->entityClassImplements(FieldableEntityInterface::class)) {
+      $field_definitions = $this->entityFieldManager->getFieldDefinitions(
+        $resource_type->getEntityTypeId(),
+        $resource_type->getBundle()
+      );
+
+      $relatable_internal = array_map(function ($field_definition) use ($resource_types) {
+        return $this->getRelatableResourceTypesFromFieldDefinition($field_definition, $resource_types);
+      }, array_filter($field_definitions, function ($field_definition) {
+        return $this->isReferenceFieldDefinition($field_definition);
+      }));
+
+      $relatable_public = [];
+      foreach ($relatable_internal as $internal_field_name => $value) {
+        $relatable_public[$resource_type->getPublicName($internal_field_name)] = $value;
+      }
+      return $relatable_public;
+    }
+    return [];
+  }
+
+  /**
+   * Get relatable resource types from a field definition.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition from which to calculate relatable JSON:API resource
+   *   types.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   A list of JSON:API resource types.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The JSON:API resource types with which the given field may have a
+   *   relationship.
+   */
+  protected function getRelatableResourceTypesFromFieldDefinition(FieldDefinitionInterface $field_definition, array $resource_types) {
+    $item_definition = $field_definition->getItemDefinition();
+
+    $entity_type_id = $item_definition->getSetting('target_type');
+    $handler_settings = $item_definition->getSetting('handler_settings');
+
+    $has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']);
+    $target_bundles = $has_target_bundles ?
+      $handler_settings['target_bundles']
+      : $this->getAllBundlesForEntityType($entity_type_id);
+
+    return array_map(function ($target_bundle) use ($entity_type_id, $resource_types) {
+      foreach ($resource_types as $resource_type) {
+        if ($resource_type->getEntityTypeId() === $entity_type_id && $resource_type->getBundle() === $target_bundle) {
+          return $resource_type;
+        }
+      }
+    }, $target_bundles);
+  }
+
+  /**
+   * Determines if a given field definition is a reference field.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition to inspect.
+   *
+   * @return bool
+   *   TRUE if the field definition is found to be a reference field. FALSE
+   *   otherwise.
+   */
+  protected function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) {
+    /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
+    $item_definition = $field_definition->getItemDefinition();
+    $main_property = $item_definition->getMainPropertyName();
+    $property_definition = $item_definition->getPropertyDefinition($main_property);
+    return $property_definition instanceof DataReferenceTargetDefinition;
+  }
+
+  /**
+   * Gets all bundle IDs for a given entity type.
+   *
+   * @param string $entity_type_id
+   *   The entity type for which to get bundles.
+   *
+   * @return string[]
+   *   The bundle IDs.
+   */
+  protected function getAllBundlesForEntityType($entity_type_id) {
+    return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepositoryInterface.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepositoryInterface.php
new file mode 100644
index 0000000000..faf6928e2e
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepositoryInterface.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Provides a repository of all JSON:API resource types.
+ *
+ * @internal
+ */
+interface ResourceTypeRepositoryInterface {
+
+  /**
+   * Gets all JSON:API resource types.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The set of all JSON:API resource types in this Drupal instance.
+   */
+  public function all();
+
+  /**
+   * Gets a specific JSON:API resource type based on entity type ID and bundle.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   * @param string $bundle
+   *   The ID for the bundle to find. If the entity type does not have a bundle,
+   *   then the entity type ID again.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   The requested JSON:API resource type, if it exists. NULL otherwise.
+   *
+   * @see \Drupal\Core\Entity\EntityInterface::bundle()
+   */
+  public function get($entity_type_id, $bundle);
+
+  /**
+   * Gets a specific JSON:API resource type based on a supplied typename.
+   *
+   * @param string $type_name
+   *   The public typename of a JSON:API resource.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType|null
+   *   The resource type, or NULL if none found.
+   */
+  public function getByTypeName($type_name);
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/InvalidVersionIdentifierException.php b/core/modules/jsonapi/src/Revisions/InvalidVersionIdentifierException.php
new file mode 100644
index 0000000000..052d1dafee
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/InvalidVersionIdentifierException.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+/**
+ * Used when a version ID is invalid.
+ *
+ * @internal
+ */
+class InvalidVersionIdentifierException extends \InvalidArgumentException {}
diff --git a/core/modules/jsonapi/src/Revisions/NegotiatorBase.php b/core/modules/jsonapi/src/Revisions/NegotiatorBase.php
new file mode 100644
index 0000000000..b14f6c5d66
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/NegotiatorBase.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+
+/**
+ * Base implementation for version negotiators.
+ *
+ * @internal
+ */
+abstract class NegotiatorBase implements VersionNegotiatorInterface {
+
+  /**
+   * The entity type manager to load the revision.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a version negotiator instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * Gets the revision ID.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param string $version_argument
+   *   A value used to derive a revision ID for the given entity.
+   *
+   * @return int
+   *   The revision ID.
+   *
+   * @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
+   *   When the revision does not exist.
+   * @throws \Drupal\jsonapi\Revisions\InvalidVersionIdentifierException
+   *   When the revision ID is not valid.
+   */
+  abstract protected function getRevisionId(EntityInterface $entity, $version_argument);
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRevision(EntityInterface $entity, $version_argument) {
+    return $this->loadRevision($entity, $this->getRevisionId($entity, $version_argument));
+  }
+
+  /**
+   * Loads an entity revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to load a revision.
+   * @param int $revision_id
+   *   The revision ID to be loaded.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface|null
+   *   The revision or NULL if the revision does not exists.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   *   Thrown if the entity type doesn't exist.
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   *   Thrown if the storage handler couldn't be loaded.
+   */
+  protected function loadRevision(EntityInterface $entity, $revision_id) {
+    $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+    $revision = static::ensureVersionExists($storage->loadRevision($revision_id));
+    if ($revision->id() !== $entity->id()) {
+      throw new VersionNotFoundException(sprintf('The requested resource does not have a version with ID %s.', $revision_id));
+    }
+    return $revision;
+  }
+
+  /**
+   * Helper method that ensures that a version exists.
+   *
+   * @param int|\Drupal\Core\Entity\EntityInterface $revision
+   *   A revision ID, or NULL if one was not found.
+   *
+   * @return int|\Drupal\Core\Entity\EntityInterface
+   *   A revision or revision ID, if one was found.
+   *
+   * @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
+   *   Thrown if the given value is NULL, meaning the requested version was not
+   *   found.
+   */
+  protected static function ensureVersionExists($revision) {
+    if (is_null($revision)) {
+      throw new VersionNotFoundException();
+    }
+    return $revision;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/ResourceVersionRouteEnhancer.php b/core/modules/jsonapi/src/Revisions/ResourceVersionRouteEnhancer.php
new file mode 100644
index 0000000000..8d7b7d159a
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/ResourceVersionRouteEnhancer.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\Core\Http\Exception\CacheableHttpException;
+use Drupal\Core\Routing\EnhancerInterface;
+use Drupal\jsonapi\Routing\Routes;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Loads an appropriate revision for the requested resource version.
+ *
+ * @internal
+ */
+final class ResourceVersionRouteEnhancer implements EnhancerInterface {
+
+  /**
+   * The route default parameter name.
+   *
+   * @var string
+   */
+  const REVISION_ID_KEY = 'revision_id';
+
+  /**
+   * The query parameter for providing a version (revision) value.
+   *
+   * @var string
+   */
+  const RESOURCE_VERSION_QUERY_PARAMETER = 'resourceVersion';
+
+  /**
+   * A route parameter key which indicates that working copies were requested.
+   *
+   * @var string
+   */
+  const WORKING_COPIES_REQUESTED = 'working_copies_requested';
+
+  /**
+   * The cache context by which vary the loaded entity revision.
+   *
+   * @var string
+   *
+   * @todo When D8 requires PHP >=5.6, convert to expression using the RESOURCE_VERSION_QUERY_PARAMETER constant.
+   */
+  const CACHE_CONTEXT = 'url.query_args:resourceVersion';
+
+  /**
+   * Resource version validation regex.
+   *
+   * @var string
+   *
+   * @todo When D8 requires PHP >=5.6, convert to expression using the VersionNegotiator::SEPARATOR constant.
+   */
+  const VERSION_IDENTIFIER_VALIDATOR = '/^[a-z]+[a-z_]*[a-z]+:[a-zA-Z0-9\-]+(:[a-zA-Z0-9\-]+)*$/';
+
+  /**
+   * The revision ID negotiator.
+   *
+   * @var \Drupal\jsonapi\Revisions\VersionNegotiator
+   */
+  protected $versionNegotiator;
+
+  /**
+   * ResourceVersionRouteEnhancer constructor.
+   *
+   * @param \Drupal\jsonapi\Revisions\VersionNegotiator $version_negotiator_manager
+   *   The version negotiator.
+   */
+  public function __construct(VersionNegotiator $version_negotiator_manager) {
+    $this->versionNegotiator = $version_negotiator_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    if (!Routes::isJsonApiRequest($defaults) || !($resource_type = Routes::getResourceTypeNameFromParameters($defaults))) {
+      return $defaults;
+    }
+
+    $has_version_param = $request->query->has(static::RESOURCE_VERSION_QUERY_PARAMETER);
+
+    // If the resource type is not versionable, then nothing needs to be
+    // enhanced.
+    if (!$resource_type->isVersionable()) {
+      // If the query parameter was provided but the resource type is not
+      // versionable, provide a helpful error.
+      if ($has_version_param) {
+        // Until Drupal core has a generic revision access API, it is only safe
+        // to support the `node` and `media` entity types because they are the
+        // only // entity types that have revision access checks for forward
+        // revisions that are not the default and not the latest revision.
+        $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
+        /* Uncomment the next line and remove the following one when https://www.drupal.org/project/drupal/issues/3002352 lands in core. */
+        /* throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.'); */
+        $message = 'JSON:API does not yet support resource versioning for this resource type.';
+        $message .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
+        $message .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
+        throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
+      }
+      return $defaults;
+    }
+
+    // Since the resource type is versionable, responses must always vary by the
+    // requested version, without regard for whether a version query parameter
+    // was provided or not.
+    if (isset($defaults['entity'])) {
+      assert($defaults['entity'] instanceof EntityInterface);
+      $defaults['entity']->addCacheContexts([static::CACHE_CONTEXT]);
+    }
+
+    // If no version was specified, nothing is left to enhance.
+    if (!$has_version_param) {
+      return $defaults;
+    }
+
+    // Provide a helpful error when a version is specified with an unsafe
+    // method.
+    if (!$request->isMethodCacheable()) {
+      throw new BadRequestHttpException(sprintf('%s requests with a `%s` query parameter are not supported.', $request->getMethod(), static::RESOURCE_VERSION_QUERY_PARAMETER));
+    }
+
+    $resource_version_identifier = $request->query->get(static::RESOURCE_VERSION_QUERY_PARAMETER);
+
+    if (!static::isValidVersionIdentifier($resource_version_identifier)) {
+      $cacheability = (new CacheableMetadata())->addCacheContexts([static::CACHE_CONTEXT]);
+      $message = sprintf('A resource version identifier was provided in an invalid format: `%s`', $resource_version_identifier);
+      throw new CacheableBadRequestHttpException($cacheability, $message);
+    }
+
+    // Determine if the request is for a collection resource.
+    if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') {
+      $latest_version_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'latest-version';
+      $working_copy_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'working-copy';
+      // Until Drupal core has a revision access API that works on entity
+      // queries, filtering is not permitted on non-default revisions.
+      if ($request->query->has('filter') && $resource_version_identifier !== $latest_version_identifier) {
+        $cache_contexts = [
+          'url.path',
+          static::CACHE_CONTEXT,
+          'url.query_args:filter',
+        ];
+        $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts);
+        $message = '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.';
+        throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
+      }
+      // 'latest-version' and 'working-copy' are the only acceptable version
+      // identifiers for a collection resource.
+      if (!in_array($resource_version_identifier, [$latest_version_identifier, $working_copy_identifier])) {
+        $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
+        $message = sprintf('Collection resources only support the following resource version identifiers: %s', implode(', ', [
+          $latest_version_identifier,
+          $working_copy_identifier,
+        ]));
+        throw new CacheableBadRequestHttpException($cacheability, $message);
+      }
+      // Whether the collection to be loaded should include only working copies.
+      $defaults[static::WORKING_COPIES_REQUESTED] = $resource_version_identifier === $working_copy_identifier;
+      return $defaults;
+    }
+
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    $entity = $defaults['entity'];
+
+    /** @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $negotiator */
+    $resolved_revision = $this->versionNegotiator->getRevision($entity, $resource_version_identifier);
+    // Ensure none of the original entity cacheability is lost, especially the
+    // query argument's cache context.
+    $resolved_revision->addCacheableDependency($entity);
+    return ['entity' => $resolved_revision] + $defaults;
+  }
+
+  /**
+   * Validates the user input.
+   *
+   * @param string $resource_version
+   *   The requested resource version identifier.
+   *
+   * @return bool
+   *   TRUE if the received resource version value is valid, FALSE otherwise.
+   */
+  protected static function isValidVersionIdentifier($resource_version) {
+    return preg_match(static::VERSION_IDENTIFIER_VALIDATOR, $resource_version) === 1;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/VersionById.php b/core/modules/jsonapi/src/Revisions/VersionById.php
new file mode 100644
index 0000000000..f3fcf6eff4
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/VersionById.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines a revision ID implementation for entity revision ID values.
+ *
+ * @internal
+ */
+class VersionById extends NegotiatorBase implements VersionNegotiatorInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getRevisionId(EntityInterface $entity, $version_argument) {
+    if (!is_numeric($version_argument)) {
+      throw new InvalidVersionIdentifierException('The revision ID must be an integer.');
+    }
+    return $version_argument;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/VersionByRel.php b/core/modules/jsonapi/src/Revisions/VersionByRel.php
new file mode 100644
index 0000000000..82af84b845
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/VersionByRel.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+
+/**
+ * Revision ID implementation for the default or latest revisions.
+ *
+ * @internal
+ */
+class VersionByRel extends NegotiatorBase {
+
+  /**
+   * Version argument which loads the revision known to be the "working copy".
+   *
+   * In Drupal terms, a "working copy" is the latest revision. It may or may not
+   * be a "default" revision. This revision is the working copy because it is
+   * the revision to which new work will be applied. In other words, it denotes
+   * the most recent revision which might be considered a work-in-progress.
+   *
+   * @var string
+   */
+  const WORKING_COPY = 'working-copy';
+
+  /**
+   * Version argument which loads the revision known to be the "latest version".
+   *
+   * In Drupal terms, the "latest version" is the latest "default" revision. It
+   * may or may not have later revisions after it, as long as none of them are
+   * "default" revisions. This revision is the latest version because it is the
+   * last revision where work was considered finished. Typically, this means
+   * that it is the most recent "published" revision.
+   *
+   * @var string
+   */
+  const LATEST_VERSION = 'latest-version';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getRevisionId(EntityInterface $entity, $version_argument) {
+    assert($entity instanceof RevisionableInterface);
+    switch ($version_argument) {
+      case static::WORKING_COPY:
+        /* @var \Drupal\Core\Entity\RevisionableStorageInterface $entity_storage */
+        $entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+        return static::ensureVersionExists($entity_storage->getLatestRevisionId($entity->id()));
+
+      case static::LATEST_VERSION:
+        // The already loaded revision will be the latest version by default.
+        // @see \Drupal\Core\Entity\Sql\SqlContentEntityStorage::buildQuery().
+        return $entity->getLoadedRevisionId();
+
+      default:
+        $message = sprintf('The version specifier must be either `%s` or `%s`, `%s` given.', static::LATEST_VERSION, static::WORKING_COPY, $version_argument);
+        throw new InvalidVersionIdentifierException($message);
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/VersionNegotiator.php b/core/modules/jsonapi/src/Revisions/VersionNegotiator.php
new file mode 100644
index 0000000000..99ae7dbe78
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/VersionNegotiator.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
+
+/**
+ * Provides a version negotiator manager.
+ *
+ * @see \Drupal\jsonapi\Revisions\VersionNegotiatorInterface
+ *
+ * @internal
+ */
+class VersionNegotiator {
+
+  /**
+   * The separator between the version negotiator name and the version argument.
+   *
+   * @var string
+   */
+  const SEPARATOR = ':';
+
+  /**
+   * An array of named version negotiators.
+   *
+   * @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface[]
+   */
+  protected $negotiators = [];
+
+  /**
+   * Adds a version negotiator.
+   *
+   * @param \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $version_negotiator
+   *   The version negotiator.
+   * @param string $negotiator_name
+   *   The name of the negotiation strategy used by the version negotiator.
+   */
+  public function addVersionNegotiator(VersionNegotiatorInterface $version_negotiator, $negotiator_name) {
+    assert(strpos(get_class($version_negotiator), 'Drupal\\jsonapi\\') === 0, 'Version negotiators are not a public API.');
+    $this->negotiators[$negotiator_name] = $version_negotiator;
+  }
+
+  /**
+   * Gets a negotiated entity revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param string $resource_version_identifier
+   *   A value used to derive a revision for the given entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The loaded revision.
+   *
+   * @throws \Drupal\Core\Http\Exception\CacheableNotFoundHttpException
+   *   When the revision does not exist.
+   * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
+   *   When the revision ID cannot be negotiated.
+   */
+  public function getRevision(EntityInterface $entity, $resource_version_identifier) {
+    try {
+      list($version_negotiator_name, $version_argument) = explode(VersionNegotiator::SEPARATOR, $resource_version_identifier, 2);
+      if (!isset($this->negotiators[$version_negotiator_name])) {
+        static::throwBadRequestHttpException($resource_version_identifier);
+      }
+      return $this->negotiators[$version_negotiator_name]->getRevision($entity, $version_argument);
+    }
+    catch (VersionNotFoundException $exception) {
+      static::throwNotFoundHttpException($entity, $resource_version_identifier);
+    }
+    catch (InvalidVersionIdentifierException $exception) {
+      static::throwBadRequestHttpException($resource_version_identifier);
+    }
+  }
+
+  /**
+   * Throws a cacheable error exception.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which a revision was requested.
+   * @param string $resource_version_identifier
+   *   The user input for the revision negotiation.
+   *
+   * @throws \Drupal\Core\Http\Exception\CacheableNotFoundHttpException
+   */
+  protected static function throwNotFoundHttpException(EntityInterface $entity, $resource_version_identifier) {
+    $cacheability = CacheableMetadata::createFromObject($entity)->addCacheContexts(['url.path', 'url.query_args:' . ResourceVersionRouteEnhancer::RESOURCE_VERSION_QUERY_PARAMETER]);
+    $reason = sprintf('The requested version, identified by `%s`, could not be found.', $resource_version_identifier);
+    throw new CacheableNotFoundHttpException($cacheability, $reason);
+  }
+
+  /**
+   * Throws a cacheable error exception.
+   *
+   * @param string $resource_version_identifier
+   *   The user input for the revision negotiation.
+   *
+   * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
+   */
+  protected static function throwBadRequestHttpException($resource_version_identifier) {
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:' . ResourceVersionRouteEnhancer::RESOURCE_VERSION_QUERY_PARAMETER]);
+    $message = sprintf('An invalid resource version identifier, `%s`, was provided.', $resource_version_identifier);
+    throw new CacheableBadRequestHttpException($cacheability, $message);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/VersionNegotiatorInterface.php b/core/modules/jsonapi/src/Revisions/VersionNegotiatorInterface.php
new file mode 100644
index 0000000000..da1426df72
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/VersionNegotiatorInterface.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines the common interface for all version negotiators.
+ *
+ * @see \Drupal\jsonapi\Revisions\VersionNegotiator
+ * @internal
+ */
+interface VersionNegotiatorInterface {
+
+  /**
+   * Gets the identified revision.
+   *
+   * The JSON:API module exposes revisions in terms of RFC5829. As such, the
+   * public API always refers to "versions" and "working copies" instead of
+   * "revisions". There are multiple ways to request a specific revision. For
+   * example, one might like to load a particular revision by its ID. On the
+   * other hand, it may be useful if an HTTP consumer is able to always request
+   * the "latest version" regardless of its ID. It is possible to imagine other
+   * scenarios as well, like fetching a revision based on a date or time.
+   *
+   * Each version negotiator provides one of these strategies and is able to map
+   * a version argument to an existing revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which a revision should be resolved.
+   * @param string $version_argument
+   *   A value used to derive a revision for the given entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The identified entity revision.
+   *
+   * @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
+   *   When the revision does not exist.
+   * @throws \Drupal\jsonapi\Revisions\InvalidVersionIdentifierException
+   *   When the revision ID is invalid.
+   */
+  public function getRevision(EntityInterface $entity, $version_argument);
+
+}
diff --git a/core/modules/jsonapi/src/Revisions/VersionNotFoundException.php b/core/modules/jsonapi/src/Revisions/VersionNotFoundException.php
new file mode 100644
index 0000000000..aa12bf44ee
--- /dev/null
+++ b/core/modules/jsonapi/src/Revisions/VersionNotFoundException.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\jsonapi\Revisions;
+
+/**
+ * Used when a version ID is valid, but the requested version does not exist.
+ *
+ * @internal
+ */
+class VersionNotFoundException extends \InvalidArgumentException {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct($message = NULL, $code = 0, \Exception $previous = NULL) {
+    parent::__construct(!is_null($message) ? $message : 'The identified version could not be found.', $code, $previous);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/DeserializationEnhancer.php b/core/modules/jsonapi/src/Routing/DeserializationEnhancer.php
new file mode 100644
index 0000000000..1c2f027c92
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/DeserializationEnhancer.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\EnhancerInterface;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\DependencyInjection\ContainerAwareInterface;
+use Symfony\Component\DependencyInjection\ContainerAwareTrait;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Serializer\Exception\InvalidArgumentException;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Processes the request query parameters.
+ *
+ * @internal
+ */
+class DeserializationEnhancer implements EnhancerInterface, ContainerAwareInterface {
+
+  use ContainerAwareTrait;
+
+  /**
+   * The JSON:API serializer.
+   *
+   * @var \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $serializer;
+
+  /**
+   * Lazily loads the JSON:API serializer.
+   *
+   * @return \Symfony\Component\Serializer\SerializerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   *   The JSON:API serializer.
+   */
+  protected function serializer() {
+    if (!$this->serializer) {
+      $this->serializer = $this->container->get('jsonapi.serializer');
+    }
+    return $this->serializer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    if (!Routes::isJsonApiRequest($defaults)) {
+      return $defaults;
+    }
+
+    $resource_type = Routes::getResourceTypeNameFromParameters($defaults);
+
+    if (isset($defaults['serialization_class']) && !$request->isMethodSafe(FALSE)) {
+      // Deserialize incoming data if available.
+      if ($received = (string) $request->getContent()) {
+        $deserialized_param_name = empty($defaults['related']) ? 'parsed_entity' : 'resource_identifiers';
+        $defaults[$deserialized_param_name] = $this->deserialize($resource_type, $received, $defaults);
+      }
+      elseif ($request->isMethod('POST') || $request->isMethod('PATCH')) {
+        throw new BadRequestHttpException('Empty request body.');
+      }
+      elseif ($request->isMethod('DELETE') && isset($defaults['related']) && $defaults['related']) {
+        throw new BadRequestHttpException(sprintf('You need to provide a body for DELETE operations on a relationship (%s).', $defaults['related']));
+      }
+    }
+
+    return $defaults;
+  }
+
+  /**
+   * Deserializes request body, if any.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for the current request.
+   * @param string $received
+   *   The request body.
+   * @param array $defaults
+   *   The route defaults.
+   *
+   * @return array
+   *   An object normalization.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown if the request body cannot be decoded, or when no request body was
+   *   provided with a POST or PATCH request.
+   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
+   *   Thrown if the request body cannot be denormalized.
+   */
+  protected function deserialize(ResourceType $resource_type, $received, array $defaults) {
+    // First decode the request data. We can then determine if the
+    // serialized data was malformed.
+    try {
+      $decoded = $this->serializer()->decode($received, 'api_json');
+    }
+    catch (UnexpectedValueException $e) {
+      // If an exception was thrown at this stage, there was a problem
+      // decoding the data. Throw a 400 http exception.
+      throw new BadRequestHttpException($e->getMessage());
+    }
+
+    try {
+      return $this->serializer()->denormalize($decoded, $defaults['serialization_class'], 'api_json', [
+        'related' => $resource_type->getInternalName(isset($defaults['related']) ? $defaults['related'] : NULL),
+        'target_entity' => isset($defaults[$resource_type->getEntityTypeId()]) ? $defaults[$resource_type->getEntityTypeId()] : NULL,
+        'resource_type' => $resource_type,
+      ]);
+    }
+    // These two serialization exception types mean there was a problem with
+    // the structure of the decoded data and it's not valid.
+    catch (UnexpectedValueException $e) {
+      throw new UnprocessableHttpEntityException($e->getMessage());
+    }
+    catch (InvalidArgumentException $e) {
+      throw new UnprocessableHttpEntityException($e->getMessage());
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/EarlyFormatSetter.php b/core/modules/jsonapi/src/Routing/EarlyFormatSetter.php
new file mode 100644
index 0000000000..61f714e124
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/EarlyFormatSetter.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\RequestFormatRouteFilter;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Sets the 'api_json' format for requests to JSON:API resources.
+ *
+ * Because this module places all JSON:API resources at paths prefixed with
+ * /jsonapi, and therefore not shared with other formats,
+ * \Drupal\Core\Routing\RequestFormatRouteFilter does correctly set the request
+ * format for those requests. However, it does so after other filters, such as
+ * \Drupal\Core\Routing\ContentTypeHeaderMatcher, run. If those other filters
+ * throw exceptions, we'd like the error response to be in JSON:API format as
+ * well, so we set that format here, in a higher priority (earlier running)
+ * filter. This works so long as the resource format can be determined before
+ * running any other filters, which is the case for JSON:API resources per
+ * above.
+ *
+ * @internal
+ */
+final class EarlyFormatSetter extends RequestFormatRouteFilter {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function filter(RouteCollection $collection, Request $request) {
+    if (is_null($request->getRequestFormat(NULL))) {
+      $possible_formats = static::getAvailableFormats($collection);
+      if ($possible_formats === ['api_json']) {
+        $request->setRequestFormat('api_json');
+      }
+    }
+    return $collection;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/RouteEnhancer.php b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
new file mode 100644
index 0000000000..d2e71f5330
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\EnhancerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * Ensures the loaded entity matches the requested resource type.
+ *
+ * @internal
+ */
+class RouteEnhancer implements EnhancerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    if (!Routes::isJsonApiRequest($defaults)) {
+      return $defaults;
+    }
+
+    $resource_type = Routes::getResourceTypeNameFromParameters($defaults);
+    $entity_type_id = $resource_type->getEntityTypeId();
+    if (!isset($defaults[$entity_type_id]) || !($entity = $defaults[$entity_type_id])) {
+      return $defaults;
+    }
+    $retrieved_bundle = $entity->bundle();
+    $configured_bundle = $resource_type->getBundle();
+    if ($retrieved_bundle != $configured_bundle) {
+      // If the bundle in the loaded entity does not match the bundle in the
+      // route (which is set based on the corresponding ResourceType), then
+      // throw an exception.
+      throw new NotFoundHttpException(sprintf('The loaded entity bundle (%s) does not match the configured resource (%s).', $retrieved_bundle, $configured_bundle));
+    }
+    return $defaults;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Routes.php b/core/modules/jsonapi/src/Routing/Routes.php
new file mode 100644
index 0000000000..e11942c3b9
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Routes.php
@@ -0,0 +1,481 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\jsonapi\Access\RelationshipFieldAccess;
+use Drupal\jsonapi\Controller\EntryPoint;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\ParamConverter\ResourceTypeConverter;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Defines dynamic routes.
+ *
+ * @internal
+ */
+class Routes implements ContainerInjectionInterface {
+
+  /**
+   * The service name for the primary JSON:API controller.
+   *
+   * All resources except the entrypoint are served by this controller.
+   *
+   * @var string
+   */
+  const CONTROLLER_SERVICE_NAME = 'jsonapi.entity_resource';
+
+  /**
+   * A key with which to flag a route as belonging to the JSON:API module.
+   *
+   * @var string
+   */
+  const JSON_API_ROUTE_FLAG_KEY = '_is_jsonapi';
+
+  /**
+   * The route default key for the route's resource type information.
+   *
+   * @var string
+   */
+  const RESOURCE_TYPE_KEY = 'resource_type';
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * List of providers.
+   *
+   * @var string[]
+   */
+  protected $providerIds;
+
+  /**
+   * The JSON:API base path.
+   *
+   * @var string
+   */
+  protected $jsonApiBasePath;
+
+  /**
+   * Instantiates a Routes object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param string[] $authentication_providers
+   *   The authentication providers, keyed by ID.
+   * @param string $jsonapi_base_path
+   *   The JSON:API base path.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, array $authentication_providers, $jsonapi_base_path) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->providerIds = array_keys($authentication_providers);
+    assert(is_string($jsonapi_base_path));
+    assert(
+      $jsonapi_base_path[0] === '/',
+      sprintf('The provided base path should contain a leading slash "/". Given: "%s".', $jsonapi_base_path)
+    );
+    assert(
+      substr($jsonapi_base_path, -1) !== '/',
+      sprintf('The provided base path should not contain a trailing slash "/". Given: "%s".', $jsonapi_base_path)
+    );
+    $this->jsonApiBasePath = $jsonapi_base_path;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('jsonapi.resource_type.repository'),
+      $container->getParameter('authentication_providers'),
+      $container->getParameter('jsonapi.base_path')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function routes() {
+    $routes = new RouteCollection();
+    $upload_routes = new RouteCollection();
+
+    // JSON:API's routes: entry point + routes for every resource type.
+    foreach ($this->resourceTypeRepository->all() as $resource_type) {
+      $routes->addCollection(static::getRoutesForResourceType($resource_type, $this->jsonApiBasePath));
+      $upload_routes->addCollection(static::getFileUploadRoutesForResourceType($resource_type, $this->jsonApiBasePath));
+    }
+    $routes->add('jsonapi.resource_list', static::getEntryPointRoute($this->jsonApiBasePath));
+
+    // Require the JSON:API media type header on every route, except on file
+    // upload routes, where we require `application/octet-stream`.
+    $routes->addRequirements(['_content_type_format' => 'api_json']);
+    $upload_routes->addRequirements(['_content_type_format' => 'bin']);
+
+    $routes->addCollection($upload_routes);
+
+    // Enable all available authentication providers.
+    $routes->addOptions(['_auth' => $this->providerIds]);
+
+    // Flag every route as belonging to the JSON:API module.
+    $routes->addDefaults([static::JSON_API_ROUTE_FLAG_KEY => TRUE]);
+
+    // All routes serve only the JSON:API media type.
+    $routes->addRequirements(['_format' => 'api_json']);
+
+    return $routes;
+  }
+
+  /**
+   * Gets applicable resource routes for a JSON:API resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type for which to get the routes.
+   * @param string $path_prefix
+   *   The root path prefix.
+   *
+   * @return \Symfony\Component\Routing\RouteCollection
+   *   A collection of routes for the given resource type.
+   */
+  protected static function getRoutesForResourceType(ResourceType $resource_type, $path_prefix) {
+    // Internal resources have no routes.
+    if ($resource_type->isInternal()) {
+      return new RouteCollection();
+    }
+
+    $routes = new RouteCollection();
+
+    // Collection route like `/jsonapi/node/article`.
+    if ($resource_type->isLocatable()) {
+      $collection_route = new Route("/{$resource_type->getPath()}");
+      $collection_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getCollection']);
+      $collection_route->setMethods(['GET']);
+      // Allow anybody access because "view" and "view label" access are checked
+      // in the controller.
+      $collection_route->setRequirement('_access', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'collection'), $collection_route);
+    }
+
+    // Creation route.
+    if ($resource_type->isMutable()) {
+      $collection_create_route = new Route("/{$resource_type->getPath()}");
+      $collection_create_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':createIndividual']);
+      $collection_create_route->setMethods(['POST']);
+      $collection_create_route->addDefaults(['serialization_class' => JsonApiDocumentTopLevel::class]);
+      $create_requirement = sprintf("%s:%s", $resource_type->getEntityTypeId(), $resource_type->getBundle());
+      $collection_create_route->setRequirement('_entity_create_access', $create_requirement);
+      $collection_create_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'collection.post'), $collection_create_route);
+    }
+
+    // Individual routes like `/jsonapi/node/article/{uuid}` or
+    // `/jsonapi/node/article/{uuid}/relationships/uid`.
+    $routes->addCollection(static::getIndividualRoutesForResourceType($resource_type));
+
+    // Add the resource type as a parameter to every resource route.
+    foreach ($routes as $route) {
+      static::addRouteParameter($route, static::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]);
+      $route->addDefaults([static::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]);
+    }
+
+    // Resource routes all have the same base path.
+    $routes->addPrefix($path_prefix);
+
+    return $routes;
+  }
+
+  /**
+   * Gets the file upload route collection for the given resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   * @param string $path_prefix
+   *   The root path prefix.
+   *
+   * @return \Symfony\Component\Routing\RouteCollection
+   *   The route collection.
+   */
+  protected static function getFileUploadRoutesForResourceType(ResourceType $resource_type, $path_prefix) {
+    $routes = new RouteCollection();
+
+    // Internal resources have no routes; individual routes require locations.
+    if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
+      return $routes;
+    }
+
+    // File upload routes are only necessary for resource types that have file
+    // fields.
+    $has_file_field = array_reduce($resource_type->getRelatableResourceTypes(), function ($carry, array $target_resource_types) {
+      return $carry || static::hasNonInternalFileTargetResourceTypes($target_resource_types);
+    }, FALSE);
+    if (!$has_file_field) {
+      return $routes;
+    }
+
+    if ($resource_type->isMutable()) {
+      $path = $resource_type->getPath();
+      $entity_type_id = $resource_type->getEntityTypeId();
+
+      $new_resource_file_upload_route = new Route("/{$path}/{file_field_name}");
+      $new_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi.file_upload:handleFileUploadForNewResource']);
+      $new_resource_file_upload_route->setMethods(['POST']);
+      $new_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getFileUploadRouteName($resource_type, 'new_resource'), $new_resource_file_upload_route);
+
+      $existing_resource_file_upload_route = new Route("/{$path}/{entity}/{file_field_name}");
+      $existing_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi.file_upload:handleFileUploadForExistingResource']);
+      $existing_resource_file_upload_route->setMethods(['POST']);
+      $existing_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getFileUploadRouteName($resource_type, 'existing_resource'), $existing_resource_file_upload_route);
+
+      // Add entity parameter conversion to every route.
+      $routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]);
+
+      // Add the resource type as a parameter to every resource route.
+      foreach ($routes as $route) {
+        static::addRouteParameter($route, static::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]);
+        $route->addDefaults([static::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]);
+      }
+    }
+
+    // File upload routes all have the same base path.
+    $routes->addPrefix($path_prefix);
+
+    return $routes;
+  }
+
+  /**
+   * Determines if the given request is for a JSON:API generated route.
+   *
+   * @param array $defaults
+   *   The request's route defaults.
+   *
+   * @return bool
+   *   Whether the request targets a generated route.
+   */
+  public static function isJsonApiRequest(array $defaults) {
+    return isset($defaults[RouteObjectInterface::CONTROLLER_NAME])
+      && strpos($defaults[RouteObjectInterface::CONTROLLER_NAME], static::CONTROLLER_SERVICE_NAME) === 0;
+  }
+
+  /**
+   * Gets a route collection for the given resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   *
+   * @return \Symfony\Component\Routing\RouteCollection
+   *   The route collection.
+   */
+  protected static function getIndividualRoutesForResourceType(ResourceType $resource_type) {
+    if (!$resource_type->isLocatable()) {
+      return new RouteCollection();
+    }
+
+    $routes = new RouteCollection();
+
+    $path = $resource_type->getPath();
+    $entity_type_id = $resource_type->getEntityTypeId();
+
+    // Individual read, update and remove.
+    $individual_route = new Route("/{$path}/{entity}");
+    $individual_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getIndividual']);
+    $individual_route->setMethods(['GET']);
+    // No _entity_access requirement because "view" and "view label" access are
+    // checked in the controller. So it's safe to allow anybody access.
+    $individual_route->setRequirement('_access', 'TRUE');
+    $routes->add(static::getRouteName($resource_type, 'individual'), $individual_route);
+    if ($resource_type->isMutable()) {
+      $individual_update_route = new Route($individual_route->getPath());
+      $individual_update_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':patchIndividual']);
+      $individual_update_route->setMethods(['PATCH']);
+      $individual_update_route->addDefaults(['serialization_class' => JsonApiDocumentTopLevel::class]);
+      $individual_update_route->setRequirement('_entity_access', "entity.update");
+      $individual_update_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'individual.patch'), $individual_update_route);
+      $individual_remove_route = new Route($individual_route->getPath());
+      $individual_remove_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':deleteIndividual']);
+      $individual_remove_route->setMethods(['DELETE']);
+      $individual_remove_route->setRequirement('_entity_access', "entity.delete");
+      $individual_remove_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getRouteName($resource_type, 'individual.delete'), $individual_remove_route);
+    }
+
+    foreach ($resource_type->getRelatableResourceTypes() as $relationship_field_name => $target_resource_types) {
+      // Read, update, add, or remove an individual resources relationships to
+      // other resources.
+      $relationship_route = new Route("/{$path}/{entity}/relationships/{$relationship_field_name}");
+      $relationship_route->addDefaults(['_on_relationship' => TRUE]);
+      $relationship_route->addDefaults(['serialization_class' => ResourceIdentifier::class]);
+      $relationship_route->addDefaults(['related' => $relationship_field_name]);
+      $relationship_route->setRequirement(RelationshipFieldAccess::ROUTE_REQUIREMENT_KEY, $relationship_field_name);
+      $relationship_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $relationship_route_methods = $resource_type->isMutable()
+        ? ['GET', 'POST', 'PATCH', 'DELETE']
+        : ['GET'];
+      $relationship_controller_methods = [
+        'GET' => 'getRelationship',
+        'POST' => 'addToRelationshipData',
+        'PATCH' => 'replaceRelationshipData',
+        'DELETE' => 'removeFromRelationshipData',
+      ];
+      foreach ($relationship_route_methods as $method) {
+        $method_specific_relationship_route = clone $relationship_route;
+        $method_specific_relationship_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ":{$relationship_controller_methods[$method]}"]);
+        $method_specific_relationship_route->setMethods($method);
+        $routes->add(static::getRouteName($resource_type, sprintf("%s.relationship.%s", $relationship_field_name, strtolower($method))), $method_specific_relationship_route);
+      }
+
+      // Only create routes for related routes that target at least one
+      // non-internal resource type.
+      if (static::hasNonInternalTargetResourceTypes($target_resource_types)) {
+        // Get an individual resource's related resources.
+        $related_route = new Route("/{$path}/{entity}/{$relationship_field_name}");
+        $related_route->setMethods(['GET']);
+        $related_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getRelated']);
+        $related_route->addDefaults(['related' => $relationship_field_name]);
+        $related_route->setRequirement(RelationshipFieldAccess::ROUTE_REQUIREMENT_KEY, $relationship_field_name);
+        $routes->add(static::getRouteName($resource_type, "$relationship_field_name.related"), $related_route);
+      }
+    }
+
+    // Add entity parameter conversion to every route.
+    $routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]);
+
+    return $routes;
+  }
+
+  /**
+   * Provides the entry point route.
+   *
+   * @param string $path_prefix
+   *   The root path prefix.
+   *
+   * @return \Symfony\Component\Routing\Route
+   *   The entry point route.
+   */
+  protected function getEntryPointRoute($path_prefix) {
+    $entry_point = new Route("/{$path_prefix}");
+    $entry_point->addDefaults([RouteObjectInterface::CONTROLLER_NAME => EntryPoint::class . '::index']);
+    $entry_point->setRequirement('_access', 'TRUE');
+    $entry_point->setMethods(['GET']);
+    return $entry_point;
+  }
+
+  /**
+   * Adds a parameter option to a route, overrides options of the same name.
+   *
+   * The Symfony Route class only has a method for adding options which
+   * overrides any previous values. Therefore, it is tedious to add a single
+   * parameter while keeping those that are already set.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to which the parameter is to be added.
+   * @param string $name
+   *   The name of the parameter.
+   * @param mixed $parameter
+   *   The parameter's options.
+   */
+  protected static function addRouteParameter(Route $route, $name, $parameter) {
+    $parameters = $route->getOption('parameters') ?: [];
+    $parameters[$name] = $parameter;
+    $route->setOption('parameters', $parameters);
+  }
+
+  /**
+   * Get a unique route name for the JSON:API resource type and route type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   * @param string $route_type
+   *   The route type. E.g. 'individual' or 'collection'.
+   *
+   * @return string
+   *   The generated route name.
+   */
+  public static function getRouteName(ResourceType $resource_type, $route_type) {
+    return sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $route_type);
+  }
+
+  /**
+   * Get a unique route name for the file upload resource type and route type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   * @param string $route_type
+   *   The route type. E.g. 'individual' or 'collection'.
+   *
+   * @return string
+   *   The generated route name.
+   */
+  protected static function getFileUploadRouteName(ResourceType $resource_type, $route_type) {
+    return sprintf('jsonapi.%s.%s.%s', $resource_type->getTypeName(), 'file_upload', $route_type);
+  }
+
+  /**
+   * Determines if an array of resource types has any non-internal ones.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resource types to check.
+   *
+   * @return bool
+   *   TRUE if there is at least one non-internal resource type in the given
+   *   array; FALSE otherwise.
+   */
+  protected static function hasNonInternalTargetResourceTypes(array $resource_types) {
+    return array_reduce($resource_types, function ($carry, ResourceType $target) {
+      return $carry || !$target->isInternal();
+    }, FALSE);
+  }
+
+  /**
+   * Determines if an array of resource types lists non-internal "file" ones.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The resource types to check.
+   *
+   * @return bool
+   *   TRUE if there is at least one non-internal "file" resource type in the
+   *   given array; FALSE otherwise.
+   */
+  protected static function hasNonInternalFileTargetResourceTypes(array $resource_types) {
+    return array_reduce($resource_types, function ($carry, ResourceType $target) {
+      return $carry || (!$target->isInternal() && $target->getEntityTypeId() === 'file');
+    }, FALSE);
+  }
+
+  /**
+   * Gets the resource type from a route or request's parameters.
+   *
+   * @param array $parameters
+   *   An array of parameters. These may be obtained from a route's
+   *   parameter defaults or from a request object.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType|null
+   *   The resource type, NULL if one cannot be found from the given parameters.
+   */
+  public static function getResourceTypeNameFromParameters(array $parameters) {
+    if (isset($parameters[static::JSON_API_ROUTE_FLAG_KEY]) && $parameters[static::JSON_API_ROUTE_FLAG_KEY]) {
+      return isset($parameters[static::RESOURCE_TYPE_KEY]) ? $parameters[static::RESOURCE_TYPE_KEY] : NULL;
+    }
+    return NULL;
+  }
+
+  /**
+   * Invalidates any JSON:API resource type dependent responses and routes.
+   */
+  public static function rebuild() {
+    \Drupal::service('cache_tags.invalidator')->invalidateTags(['jsonapi_resource_types']);
+    \Drupal::service('router.builder')->setRebuildNeeded();
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Serializer/Serializer.php b/core/modules/jsonapi/src/Serializer/Serializer.php
new file mode 100644
index 0000000000..0ea0442225
--- /dev/null
+++ b/core/modules/jsonapi/src/Serializer/Serializer.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\jsonapi\Serializer;
+
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Symfony\Component\Serializer\Serializer as SymfonySerializer;
+
+/**
+ * Overrides the Symfony serializer to cordon off our incompatible normalizers.
+ *
+ * This service is for *internal* use only. It is not suitable for *any* reuse.
+ * Backwards compatibility is in no way guaranteed and will almost certainly be
+ * broken in the future.
+ *
+ * @link https://www.drupal.org/project/jsonapi/issues/2923779#comment-12407443
+ *
+ * @internal
+ */
+final class Serializer extends SymfonySerializer {
+
+  /**
+   * A normalizer to fall back on when JSON:API cannot normalize an object.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\NormalizerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $fallbackNormalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $normalizers = [], array $encoders = []) {
+    foreach ($normalizers as $normalizer) {
+      if (strpos(get_class($normalizer), 'Drupal\jsonapi\Normalizer') !== 0) {
+        throw new \LogicException('JSON:API does not allow adding more normalizers!');
+      }
+    }
+    parent::__construct($normalizers, $encoders);
+  }
+
+  /**
+   * Adds a secondary normalizer.
+   *
+   * This normalizer will be attempted when JSON:API has no applicable
+   * normalizer.
+   *
+   * @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer
+   *   The secondary normalizer.
+   */
+  public function setFallbackNormalizer(NormalizerInterface $normalizer) {
+    $this->fallbackNormalizer = $normalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($data, $format = NULL, array $context = []) {
+    if ($this->selfSupportsNormalization($data, $format, $context)) {
+      return parent::normalize($data, $format, $context);
+    }
+    if ($this->fallbackNormalizer->supportsNormalization($data, $format, $context)) {
+      return $this->fallbackNormalizer->normalize($data, $format, $context);
+    }
+    return parent::normalize($data, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $type, $format = NULL, array $context = []) {
+    if ($this->selfSupportsDenormalization($data, $type, $format, $context)) {
+      return parent::denormalize($data, $type, $format, $context);
+    }
+    return $this->fallbackNormalizer->denormalize($data, $type, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL, array $context = []) {
+    return $this->selfSupportsNormalization($data, $format, $context) || $this->fallbackNormalizer->supportsNormalization($data, $format, $context);
+  }
+
+  /**
+   * Checks whether this class alone supports normalization.
+   *
+   * @param mixed $data
+   *   Data to normalize.
+   * @param string $format
+   *   The format being (de-)serialized from or into.
+   * @param array $context
+   *   (optional) Options available to the normalizer.
+   *
+   * @return bool
+   *   Whether this class supports normalization for the given data.
+   */
+  private function selfSupportsNormalization($data, $format = NULL, array $context = []) {
+    return parent::supportsNormalization($data, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL, array $context = []) {
+    return $this->selfSupportsDenormalization($data, $type, $format, $context) || $this->fallbackNormalizer->supportsDenormalization($data, $type, $format, $context);
+  }
+
+  /**
+   * Checks whether this class alone supports denormalization.
+   *
+   * @param mixed $data
+   *   Data to denormalize from.
+   * @param string $type
+   *   The class to which the data should be denormalized.
+   * @param string $format
+   *   The format being deserialized from.
+   * @param array $context
+   *   (optional) Options available to the denormalizer.
+   *
+   * @return bool
+   *   Whether this class supports normalization for the given data and type.
+   */
+  private function selfSupportsDenormalization($data, $type, $format = NULL, array $context = []) {
+    return parent::supportsDenormalization($data, $type, $format, $context);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.info.yml
new file mode 100644
index 0000000000..a5664b7197
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.info.yml
@@ -0,0 +1,4 @@
+name: 'JSON API test collection counts'
+type: module
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.services.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.services.yml
new file mode 100644
index 0000000000..8ca04a7811
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/jsonapi_test_collection_count.services.yml
@@ -0,0 +1,6 @@
+services:
+  count.jsonapi.resource_type.repository:
+    class: Drupal\jsonapi_test_collection_count\ResourceType\CountableResourceTypeRepository
+    public: false
+    decorates: jsonapi.resource_type.repository
+    parent: jsonapi.resource_type.repository
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceType.php b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceType.php
new file mode 100644
index 0000000000..9484d196dc
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceType.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\jsonapi_test_collection_count\ResourceType;
+
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Subclass with overridden ::includeCount() for testing purposes.
+ */
+class CountableResourceType extends ResourceType {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function includeCount() {
+    return TRUE;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
new file mode 100644
index 0000000000..33c9c1a4d1
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\jsonapi_test_collection_count\ResourceType;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+
+/**
+ * Provides a repository of JSON:API configurable resource types.
+ */
+class CountableResourceTypeRepository extends ResourceTypeRepository {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createResourceType(EntityTypeInterface $entity_type, $bundle) {
+    $raw_fields = $this->getAllFieldNames($entity_type, $bundle);
+    return new CountableResourceType(
+      $entity_type->id(),
+      $bundle,
+      $entity_type->getClass(),
+      $entity_type->isInternal(),
+      static::isLocatableResourceType($entity_type, $bundle),
+      static::isMutableResourceType($entity_type, $bundle),
+      static::getFieldMapping($raw_fields, $entity_type, $bundle)
+    );
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.info.yml
new file mode 100644
index 0000000000..a72ccb10ac
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.info.yml
@@ -0,0 +1,4 @@
+name: 'JSON API test format-agnostic @DataType normalizers'
+type: module
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.services.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.services.yml
new file mode 100644
index 0000000000..2ad6cd99ba
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.services.yml
@@ -0,0 +1,10 @@
+services:
+  serializer.normalizer.string.jsonapi_test_data_type:
+    class: Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer
+    tags:
+      # The priority must be higher than serializer.normalizer.primitive_data.
+      - { name: normalizer , priority: 1000 }
+  serializer.normalizer.traversable_object.jsonapi_test_data_type:
+    class: Drupal\jsonapi_test_data_type\Normalizer\TraversableObjectNormalizer
+    tags:
+      - { name: normalizer }
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/StringNormalizer.php b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/StringNormalizer.php
new file mode 100644
index 0000000000..9dd4851574
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/StringNormalizer.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\jsonapi_test_data_type\Normalizer;
+
+use Drupal\Core\TypedData\Plugin\DataType\StringData;
+use Drupal\serialization\Normalizer\NormalizerBase;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Normalizes string data weirdly: replaces 'super' with 'NOT' and vice versa.
+ */
+class StringNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = StringData::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    return str_replace('super', 'NOT', $object->getValue());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    return str_replace('NOT', 'super', $data);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/TraversableObjectNormalizer.php b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/TraversableObjectNormalizer.php
new file mode 100644
index 0000000000..89e5967134
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/TraversableObjectNormalizer.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\jsonapi_test_data_type\Normalizer;
+
+use Drupal\jsonapi_test_data_type\TraversableObject;
+use Drupal\serialization\Normalizer\NormalizerBase;
+
+/**
+ * Normalizes TraversableObject.
+ */
+class TraversableObjectNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = TraversableObject::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    return $object->property;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/TraversableObject.php b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/TraversableObject.php
new file mode 100644
index 0000000000..79b44fc696
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/TraversableObject.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\jsonapi_test_data_type;
+
+/**
+ * An object which implements \IteratorAggregate.
+ */
+class TraversableObject implements \IteratorAggregate {
+
+  public $property = "value";
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator() {
+    return new \ArrayIterator();
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.info.yml
new file mode 100644
index 0000000000..4cfaf77cfe
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.info.yml
@@ -0,0 +1,5 @@
+name: 'JSON API field access'
+type: module
+description: 'Provides a custom field access hook to test JSON API field access security.'
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.module b/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.module
new file mode 100644
index 0000000000..20f2de8284
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.module
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for testing the JSON:API module.
+ */
+
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Implements hook_entity_field_access().
+ */
+function jsonapi_test_field_access_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account) {
+  // @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::testRelationships().
+  if ($field_definition->getName() === 'field_jsonapi_test_entity_ref') {
+    // Forbid access in all cases.
+    $permission = "field_jsonapi_test_entity_ref $operation access";
+    $access_result = $account->hasPermission($permission)
+      ? AccessResult::allowed()
+      : AccessResult::forbidden("The '$permission' permission is required.");
+    return $access_result->addCacheContexts(['user.permissions']);
+  }
+
+  // No opinion.
+  return AccessResult::neutral();
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.info.yml
new file mode 100644
index 0000000000..0ffa03d4ac
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.info.yml
@@ -0,0 +1,5 @@
+name: 'JSON:API filter access'
+type: module
+description: 'Provides custom access related code to test JSON:API filter security.'
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.module b/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.module
new file mode 100644
index 0000000000..c8ce4b822b
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_filter_access/jsonapi_test_field_filter_access.module
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for testing the JSON:API module.
+ */
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Implements hook_jsonapi_entity_field_field_access().
+ */
+function jsonapi_test_field_filter_access_jsonapi_entity_field_filter_access(FieldDefinitionInterface $field_definition, AccountInterface $account) {
+  if ($field_definition->getName() === 'spotlight') {
+    return AccessResult::forbiddenIf(!$account->hasPermission('filter by spotlight field'))->cachePerPermissions();
+  }
+  if ($field_definition->getName() === 'field_test_text') {
+    return AccessResult::allowedIf($field_definition->getTargetEntityTypeId() === 'entity_test_with_bundle');
+  }
+  return AccessResult::neutral();
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.info.yml
new file mode 100644
index 0000000000..30944dbb75
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.info.yml
@@ -0,0 +1,4 @@
+name: 'JSON API test format-agnostic @FieldType normalizers'
+type: module
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.services.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.services.yml
new file mode 100644
index 0000000000..ebb51ed487
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/jsonapi_test_field_type.services.yml
@@ -0,0 +1,6 @@
+services:
+  serializer.normalizer.string.jsonapi_test_field_type:
+    class: Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer
+    tags:
+      # The priority must be higher than serialization.normalizer.field_item.
+      - { name: normalizer , priority: 1000 }
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Normalizer/StringNormalizer.php b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Normalizer/StringNormalizer.php
new file mode 100644
index 0000000000..2f3f70e0ef
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Normalizer/StringNormalizer.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\jsonapi_test_field_type\Normalizer;
+
+use Drupal\Core\Field\Plugin\Field\FieldType\StringItem;
+use Drupal\serialization\Normalizer\FieldItemNormalizer;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Normalizes string fields weirdly: replaces 'super' with 'NOT' and vice versa.
+ */
+class StringNormalizer extends FieldItemNormalizer implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = StringItem::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    $data = parent::normalize($object, $format, $context);
+    $data['value'] = str_replace('super', 'NOT', $data['value']);
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function constructValue($data, $context) {
+    $data = parent::constructValue($data, $context);
+    $data['value'] = str_replace('NOT', 'super', $data['value']);
+    return $data;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.info.yml
new file mode 100644
index 0000000000..b8ab6476e1
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.info.yml
@@ -0,0 +1,4 @@
+name: 'JSON API test: normalizers kernel tests, public aliases for select JSON API normalizers'
+type: module
+package: Testing
+core: 8.x
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.services.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.services.yml
new file mode 100644
index 0000000000..347683cd93
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_normalizers_kernel/jsonapi_test_normalizers_kernel.services.yml
@@ -0,0 +1,4 @@
+services:
+  jsonapi_test_normalizers_kernel.jsonapi_document_toplevel:
+    alias: serializer.normalizer.jsonapi_document_toplevel.jsonapi
+    public: true
diff --git a/core/modules/jsonapi/tests/src/Functional/ActionTest.php b/core/modules/jsonapi/tests/src/Functional/ActionTest.php
new file mode 100644
index 0000000000..796494c21d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ActionTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\system\Entity\Action;
+use Drupal\user\RoleInterface;
+
+/**
+ * JSON:API integration test for the "Action" config entity type.
+ *
+ * @group jsonapi
+ */
+class ActionTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'action';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'action--action';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\system\ActionConfigEntityInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer actions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $action = Action::create([
+      'id' => 'user_add_role_action.' . RoleInterface::ANONYMOUS_ID,
+      'type' => 'user',
+      'label' => t('Add the anonymous role to the selected users'),
+      'configuration' => [
+        'rid' => RoleInterface::ANONYMOUS_ID,
+      ],
+      'plugin' => 'user_add_role_action',
+    ]);
+    $action->save();
+
+    return $action;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/action/action/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'action--action',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'configuration' => [
+            'rid' => 'anonymous',
+          ],
+          'dependencies' => [
+            'config' => ['user.role.anonymous'],
+            'module' => ['user'],
+          ],
+          'label' => 'Add the anonymous role to the selected users',
+          'langcode' => 'en',
+          'plugin' => 'user_add_role_action',
+          'status' => TRUE,
+          'action_type' => 'user',
+          'drupal_internal__id' => 'user_add_role_action.anonymous',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php b/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php
new file mode 100644
index 0000000000..98c56d0502
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Field\Entity\BaseFieldOverride;
+use Drupal\Core\Url;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for the "BaseFieldOverride" config entity type.
+ *
+ * @group jsonapi
+ */
+class BaseFieldOverrideTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['field', 'node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'base_field_override';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'base_field_override--base_field_override';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Field\Entity\BaseFieldOverride
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer node fields']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+    $camelids->save();
+
+    $entity = BaseFieldOverride::create([
+      'field_name' => 'promote',
+      'entity_type' => 'node',
+      'bundle' => 'camelids',
+    ]);
+    $entity->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/base_field_override/base_field_override/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'base_field_override--base_field_override',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'default_value' => [],
+          'default_value_callback' => '',
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+          ],
+          'description' => '',
+          'entity_type' => 'node',
+          'field_name' => 'promote',
+          'field_type' => 'boolean',
+          'label' => NULL,
+          'langcode' => 'en',
+          'required' => FALSE,
+          'settings' => [
+            'on_label' => 'On',
+            'off_label' => 'Off',
+          ],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'drupal_internal__id' => 'node.camelids.promote',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer node fields' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    $entity = BaseFieldOverride::create([
+      'field_name' => 'status',
+      'entity_type' => 'node',
+      'bundle' => 'camelids',
+    ]);
+    $entity->save();
+    return $entity;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/BlockContentTest.php b/core/modules/jsonapi/tests/src/Functional/BlockContentTest.php
new file mode 100644
index 0000000000..3168383e02
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockContentTest.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+
+/**
+ * JSON:API integration test for the "BlockContent" content entity type.
+ *
+ * @group jsonapi
+ */
+class BlockContentTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block_content'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block_content';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'block_content--basic';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\block_content\BlockContentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer blocks']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createEntity() {
+    if (!BlockContentType::load('basic')) {
+      $block_content_type = BlockContentType::create([
+        'id' => 'basic',
+        'label' => 'basic',
+        'revision' => TRUE,
+      ]);
+      $block_content_type->save();
+      block_content_add_body_field($block_content_type->id());
+    }
+
+    // Create a "Llama" custom block.
+    $block_content = BlockContent::create([
+      'info' => 'Llama',
+      'type' => 'basic',
+      'body' => [
+        'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+        'format' => 'plain_text',
+      ],
+    ])
+      ->setUnpublished();
+    $block_content->save();
+    return $block_content;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/block_content/basic/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block_content--basic',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'body' => [
+            'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+            'format' => 'plain_text',
+            'summary' => NULL,
+            'processed' => "<p>The name &quot;llama&quot; was adopted by European settlers from native Peruvians.</p>\n",
+          ],
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'info' => 'Llama',
+          'revision_log' => NULL,
+          'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'revision_translation_affected' => TRUE,
+          'status' => FALSE,
+          'langcode' => 'en',
+          'default_langcode' => TRUE,
+          'drupal_internal__id' => 1,
+          'drupal_internal__revision_id' => 1,
+          'reusable' => TRUE,
+        ],
+        'relationships' => [
+          'block_content_type' => [
+            'data' => [
+              'id' => BlockContentType::load('basic')->uuid(),
+              'type' => 'block_content_type--block_content_type',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/block_content_type'],
+              'self' => ['href' => $self_url . '/relationships/block_content_type'],
+            ],
+          ],
+          'revision_user' => [
+            'data' => NULL,
+            'links' => [
+              'related' => ['href' => $self_url . '/revision_user'],
+              'self' => ['href' => $self_url . '/relationships/revision_user'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'block_content--basic',
+        'attributes' => [
+          'info' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\block_content\BlockContentAccessControlHandler()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['block_content:1']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
+    $tags = parent::getExpectedCacheTags($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('body', $sparse_fieldset)) {
+      $tags = Cache::mergeTags($tags, ['config:filter.format.plain_text']);
+    }
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    $contexts = parent::getExpectedCacheContexts($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('body', $sparse_fieldset)) {
+      $contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
+    }
+    return $contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $this->entity->setPublished()->save();
+    $this->doTestCollectionFilterAccessForPublishableEntities('info', NULL, 'administer blocks');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/BlockContentTypeTest.php b/core/modules/jsonapi/tests/src/Functional/BlockContentTypeTest.php
new file mode 100644
index 0000000000..0abe7283d0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockContentTypeTest.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "BlockContentType" config entity type.
+ *
+ * @group jsonapi
+ */
+class BlockContentTypeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block_content'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block_content_type';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'block_content_type--block_content_type';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\block_content\BlockContentTypeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer blocks']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $block_content_type = BlockContentType::create([
+      'id' => 'pascal',
+      'label' => 'Pascal',
+      'revision' => FALSE,
+      'description' => 'Provides a competitive alternative to the "basic" type',
+    ]);
+
+    $block_content_type->save();
+
+    return $block_content_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/block_content_type/block_content_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block_content_type--block_content_type',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Provides a competitive alternative to the "basic" type',
+          'label' => 'Pascal',
+          'langcode' => 'en',
+          'revision' => 0,
+          'status' => TRUE,
+          'drupal_internal__id' => 'pascal',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/BlockTest.php b/core/modules/jsonapi/tests/src/Functional/BlockTest.php
new file mode 100644
index 0000000000..3827b07019
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockTest.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\block\Entity\Block;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "Block" config entity type.
+ *
+ * @group jsonapi
+ */
+class BlockTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'block--block';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\block\BlockInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->entity->setVisibilityConfig('user_role', [])->save();
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $block = Block::create([
+      'plugin' => 'llama_block',
+      'region' => 'header',
+      'id' => 'llama',
+      'theme' => 'classy',
+    ]);
+    // All blocks can be viewed by the anonymous user by default. An interesting
+    // side effect of this is that any anonymous user is also able to read the
+    // corresponding block config entity via REST, even if an authentication
+    // provider is configured for the block config entity REST resource! In
+    // other words: Block entities do not distinguish between 'view' as in
+    // "render on a page" and 'view' as in "read the configuration".
+    // This prevents that.
+    // @todo Fix this in https://www.drupal.org/node/2820315.
+    $block->setVisibilityConfig('user_role', [
+      'id' => 'user_role',
+      'roles' => ['non-existing-role' => 'non-existing-role'],
+      'negate' => FALSE,
+      'context_mapping' => [
+        'user' => '@user.current_user_context:current_user',
+      ],
+    ]);
+    $block->save();
+
+    return $block;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/block/block/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block--block',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'weight' => NULL,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [
+            'theme' => [
+              'classy',
+            ],
+          ],
+          'theme' => 'classy',
+          'region' => 'header',
+          'provider' => NULL,
+          'plugin' => 'llama_block',
+          'settings' => [
+            'id' => 'broken',
+            'label' => '',
+            'provider' => 'core',
+            'label_display' => 'visible',
+          ],
+          'visibility' => [],
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update once https://www.drupal.org/node/2300677 is fixed.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    // @see ::createEntity()
+    return array_values(array_diff(parent::getExpectedCacheContexts(), ['user.permissions']));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
+    // Because the 'user.permissions' cache context is missing, the cache tag
+    // for the anonymous user role is never added automatically.
+    return array_values(array_diff(parent::getExpectedCacheTags(), ['config:user.role.anonymous']));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The block visibility condition 'user_role' denied access.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\block\BlockAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->setCacheTags([
+        '4xx-response',
+        'config:block.block.llama',
+        'http_response',
+        'user:2',
+      ])
+      ->setCacheContexts(['url.site', 'user.roles']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    return parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered)
+      ->addCacheTags(['user:2'])
+      ->addCacheContexts(['user.roles']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/CommentTest.php b/core/modules/jsonapi/tests/src/Functional/CommentTest.php
new file mode 100644
index 0000000000..35cf1e6610
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/CommentTest.php
@@ -0,0 +1,465 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\comment\Entity\Comment;
+use Drupal\comment\Entity\CommentType;
+use Drupal\comment\Tests\CommentTestTrait;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "Comment" content entity type.
+ *
+ * @group jsonapi
+ */
+class CommentTest extends ResourceTestBase {
+
+  use CommentTestTrait;
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['comment', 'entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'comment';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'comment--comment';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'status' => "The 'administer comments' permission is required.",
+    'name' => "The 'administer comments' permission is required.",
+    'homepage' => "The 'administer comments' permission is required.",
+    'created' => "The 'administer comments' permission is required.",
+    'changed' => NULL,
+    'thread' => NULL,
+    'entity_type' => NULL,
+    'field_name' => NULL,
+    // @todo Uncomment this after https://www.drupal.org/project/drupal/issues/1847608 lands. Until then, it's impossible to test this.
+    // 'pid' => NULL,
+    'uid' => "The 'administer comments' permission is required.",
+    'entity_id' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\comment\CommentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access comments', 'view test entity']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['post comments']);
+        break;
+
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['edit own comments']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer comments']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "bar" bundle for the "entity_test" entity type and create.
+    $bundle = 'bar';
+    entity_test_create_bundle($bundle, NULL, 'entity_test');
+
+    // Create a comment field on this bundle.
+    $this->addDefaultCommentField('entity_test', 'bar', 'comment');
+
+    // Create a "Camelids" test entity that the comment will be assigned to.
+    $commented_entity = EntityTest::create([
+      'name' => 'Camelids',
+      'type' => 'bar',
+    ]);
+    $commented_entity->save();
+
+    // Create a "Llama" comment.
+    $comment = Comment::create([
+      'comment_body' => [
+        'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+        'format' => 'plain_text',
+      ],
+      'entity_id' => $commented_entity->id(),
+      'entity_type' => 'entity_test',
+      'field_name' => 'comment',
+    ]);
+    $comment->setSubject('Llama')
+      ->setOwnerId($this->account->id())
+      ->setPublished()
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789);
+    $comment->save();
+
+    return $comment;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/comment/comment/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'comment--comment',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => '1973-11-29T21:33:09+00:00',
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'comment_body' => [
+            'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+            'format' => 'plain_text',
+            'processed' => "<p>The name &quot;llama&quot; was adopted by European settlers from native Peruvians.</p>\n",
+          ],
+          'default_langcode' => TRUE,
+          'entity_type' => 'entity_test',
+          'field_name' => 'comment',
+          'homepage' => NULL,
+          'langcode' => 'en',
+          'name' => NULL,
+          'status' => TRUE,
+          'subject' => 'Llama',
+          'thread' => '01/',
+          'drupal_internal__cid' => 1,
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/uid'],
+              'self' => ['href' => $self_url . '/relationships/uid'],
+            ],
+          ],
+          'comment_type' => [
+            'data' => [
+              'id' => CommentType::load('comment')->uuid(),
+              'type' => 'comment_type--comment_type',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/comment_type'],
+              'self' => ['href' => $self_url . '/relationships/comment_type'],
+            ],
+          ],
+          'entity_id' => [
+            'data' => [
+              'id' => EntityTest::load(1)->uuid(),
+              'type' => 'entity_test--bar',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/entity_id'],
+              'self' => ['href' => $self_url . '/relationships/entity_id'],
+            ],
+          ],
+          'pid' => [
+            'data' => NULL,
+            'links' => [
+              'related' => ['href' => $self_url . '/pid'],
+              'self' => ['href' => $self_url . '/relationships/pid'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'comment--comment',
+        'attributes' => [
+          'entity_type' => 'entity_test',
+          'field_name' => 'comment',
+          'subject' => 'Dramallama',
+          'comment_body' => [
+            'value' => 'Llamas are awesome.',
+            'format' => 'plain_text',
+          ],
+        ],
+        'relationships' => [
+          'entity_id' => [
+            'data' => [
+              'type' => 'entity_test--bar',
+              'id' => EntityTest::load(1)->uuid(),
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
+    $tags = parent::getExpectedCacheTags($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('comment_body', $sparse_fieldset)) {
+      $tags = Cache::mergeTags($tags, ['config:filter.format.plain_text']);
+    }
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    $contexts = parent::getExpectedCacheContexts($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('comment_body', $sparse_fieldset)) {
+      $contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
+    }
+    return $contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET';
+        return "The 'access comments' permission is required and the comment must be published.";
+
+      case 'POST';
+        return "The 'post comments' permission is required.";
+
+      case 'PATCH':
+        return "The 'edit own comments' permission is required, the user must be the comment author, and the comment must be published.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests POSTing a comment without critical base fields.
+   *
+   * Note that testPostIndividual() is testing with the most minimal
+   * normalization possible: the one returned by ::getNormalizedPostEntity().
+   *
+   * But Comment entities have some very special edge cases:
+   * - base fields that are not marked as required in
+   *   \Drupal\comment\Entity\Comment::baseFieldDefinitions() yet in fact are
+   *   required.
+   * - base fields that are marked as required, but yet can still result in
+   *   validation errors other than "missing required field".
+   */
+  public function testPostIndividualDxWithoutCriticalBaseFields() {
+    $this->setUpAuthorization('POST');
+
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    $remove_field = function (array $normalization, $type, $attribute_name) {
+      unset($normalization['data'][$type][$attribute_name]);
+      return $normalization;
+    };
+
+    // DX: 422 when missing 'entity_type' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'attributes', 'entity_type'));
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(422, 'entity_type: This value should not be null.', NULL, $response, '/data/attributes/entity_type');
+
+    // DX: 422 when missing 'entity_id' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'relationships', 'entity_id'));
+    // @todo Remove the try/catch in https://www.drupal.org/node/2820364.
+    try {
+      $response = $this->request('POST', $url, $request_options);
+      if (floatval(\Drupal::VERSION) >= 8.7) {
+        $this->assertResourceErrorResponse(422, 'entity_id: This value should not be null.', NULL, $response, '/data/attributes/entity_id');
+      }
+    }
+    catch (\Exception $e) {
+      if (version_compare(phpversion(), '7.0') >= 0) {
+        $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
+      }
+      else {
+        $this->assertSame(500, $response->getStatusCode());
+      }
+    }
+
+    // DX: 422 when missing 'field_name' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getPostDocument(), 'attributes', 'field_name'));
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(422, 'field_name: This value should not be null.', NULL, $response, '/data/attributes/field_name');
+  }
+
+  /**
+   * Tests POSTing a comment with and without 'skip comment approval'.
+   */
+  public function testPostIndividualSkipCommentApproval() {
+    $this->setUpAuthorization('POST');
+
+    // Create request.
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument());
+
+    $url = Url::fromRoute('jsonapi.comment--comment.collection.post');
+
+    // Status should be FALSE when posting as anonymous.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertFalse(Json::decode((string) $response->getBody())['data']['attributes']['status']);
+    $this->assertFalse($this->entityStorage->loadUnchanged(2)->isPublished());
+
+    // Grant anonymous permission to skip comment approval.
+    $this->grantPermissionsToTestedRole(['skip comment approval']);
+
+    // Status must be TRUE when posting as anonymous and skip comment approval.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertTrue(Json::decode((string) $response->getBody())['data']['attributes']['status']);
+    $this->assertTrue($this->entityStorage->loadUnchanged(3)->isPublished());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['comment:1']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // Also reset the 'entity_test' entity access control handler because
+    // comment access also depends on access to the commented entity type.
+    \Drupal::entityTypeManager()->getAccessControlHandler('entity_test')->resetCache();
+    return parent::entityAccess($entity, $operation, $account);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getIncludePermissions() {
+    return [
+      'type' => ['administer comment types'],
+      'uid' => ['access user profiles'],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    // Verify the expected behavior in the common case.
+    $this->doTestCollectionFilterAccessForPublishableEntities('subject', 'access comments', 'administer comments');
+
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Go back to a simpler scenario: revoke the admin permission, publish the
+    // comment and uninstall the query access test module.
+    $this->revokePermissionsFromTestedRole(['administer comments']);
+    $this->entity->setPublished()->save();
+    $this->assertTrue($this->container->get('module_installer')->uninstall(['jsonapi_test_field_filter_access'], TRUE), 'Uninstalled modules.');
+    // ?filter[spotlight.LABEL]: 1 result. Just as already tested above in
+    // ::doTestCollectionFilterAccessForPublishableEntities().
+    $collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.subject]" => $this->entity->label()]);
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    // Mark the commented entity as inaccessible.
+    \Drupal::state()->set('jsonapi__entity_test_filter_access_blacklist', [$this->entity->getCommentedEntityId()]);
+    Cache::invalidateTags(['state:jsonapi__entity_test_filter_access_blacklist']);
+    // ?filter[spotlight.LABEL]: 0 results.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
+    if ($filtered) {
+      $cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
+    }
+    return $cacheability;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    // Ensure ::getModifiedEntityForPatchTesting() can pick an alternative value
+    // for the 'entity_id' field.
+    EntityTest::create([
+      'name' => $this->randomString(),
+      'type' => 'bar',
+    ])->save();
+
+    return parent::testPatchIndividual();
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/CommentTypeTest.php b/core/modules/jsonapi/tests/src/Functional/CommentTypeTest.php
new file mode 100644
index 0000000000..26ce5bac86
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/CommentTypeTest.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\comment\Entity\CommentType;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "CommentType" config entity type.
+ *
+ * @group jsonapi
+ */
+class CommentTypeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'comment'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'comment_type';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'comment_type--comment_type';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\comment\CommentTypeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer comment types']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" comment type.
+    $camelids = CommentType::create([
+      'id' => 'camelids',
+      'label' => 'Camelids',
+      'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+      'target_entity_type_id' => 'node',
+    ]);
+
+    $camelids->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/comment_type/comment_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'comment_type--comment_type',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+          'label' => 'Camelids',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'target_entity_type_id' => 'node',
+          'drupal_internal__id' => 'camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ConfigTestTest.php b/core/modules/jsonapi/tests/src/Functional/ConfigTestTest.php
new file mode 100644
index 0000000000..e6d873d04f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ConfigTestTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "ConfigTest" config entity type.
+ *
+ * @group jsonapi
+ */
+class ConfigTestTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['config_test', 'config_test_rest'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'config_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'config_test--config_test';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\config_test\ConfigTestInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['view config_test']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'view config_test' permission is required.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $config_test = ConfigTest::create([
+      'id' => 'llama',
+      'label' => 'Llama',
+    ]);
+    $config_test->save();
+
+    return $config_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/config_test/config_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'config_test--config_test',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'weight' => 0,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'label' => 'Llama',
+          'style' => NULL,
+          'size' => NULL,
+          'size_value' => NULL,
+          'protected_property' => NULL,
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ConfigurableLanguageTest.php b/core/modules/jsonapi/tests/src/Functional/ConfigurableLanguageTest.php
new file mode 100644
index 0000000000..3aa479745e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ConfigurableLanguageTest.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\language\Entity\ConfigurableLanguage;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "ConfigurableLanguage" config entity type.
+ *
+ * @group jsonapi
+ */
+class ConfigurableLanguageTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['language'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'configurable_language';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'configurable_language--configurable_language';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Field\Entity\BaseFieldOverride
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer languages']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $configurable_language = ConfigurableLanguage::create([
+      'id' => 'll',
+      'label' => 'Llama Language',
+    ]);
+    $configurable_language->save();
+
+    return $configurable_language;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/configurable_language/configurable_language/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'configurable_language--configurable_language',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'direction' => 'ltr',
+          'label' => 'Llama Language',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'status' => TRUE,
+          'weight' => 0,
+          'drupal_internal__id' => 'll',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['languages:language_interface']);
+  }
+
+  /**
+   * Test a GET request for a default config entity, which has a _core key.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2915539
+   */
+  public function testGetIndividualDefaultConfig() {
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute('jsonapi.configurable_language--configurable_language.individual', ['entity' => ConfigurableLanguage::load('en')->uuid()]);
+    /* $url = ConfigurableLanguage::load('en')->toUrl('jsonapi'); */
+
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $this->setUpAuthorization('GET');
+    $response = $this->request('GET', $url, $request_options);
+
+    $normalization = Json::decode((string) $response->getBody());
+    $this->assertArrayNotHasKey('_core', $normalization['data']['attributes']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ContactFormTest.php b/core/modules/jsonapi/tests/src/Functional/ContactFormTest.php
new file mode 100644
index 0000000000..993cff56ef
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ContactFormTest.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "ContactForm" config entity type.
+ *
+ * @group jsonapi
+ */
+class ContactFormTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['contact'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'contact_form';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'contact_form--contact_form';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\contact\ContactFormInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['access site-wide contact form']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $contact_form = ContactForm::create([
+      'id' => 'llama',
+      'label' => 'Llama',
+      'message' => 'Let us know what you think about llamas',
+      'reply' => 'Llamas are indeed awesome!',
+      'recipients' => [
+        'llama@example.com',
+        'contact@example.com',
+      ],
+    ]);
+    $contact_form->save();
+
+    return $contact_form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/contact_form/contact_form/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'contact_form--contact_form',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'label' => 'Llama',
+          'langcode' => 'en',
+          'message' => 'Let us know what you think about llamas',
+          'recipients' => [
+            'llama@example.com',
+            'contact@example.com',
+          ],
+          'redirect' => NULL,
+          'reply' => 'Llamas are indeed awesome!',
+          'status' => TRUE,
+          'weight' => 0,
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'access site-wide contact form' permission is required.";
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ContentLanguageSettingsTest.php b/core/modules/jsonapi/tests/src/Functional/ContentLanguageSettingsTest.php
new file mode 100644
index 0000000000..9eb9da53fe
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ContentLanguageSettingsTest.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\language\Entity\ContentLanguageSettings;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for "ContentLanguageSettings" config entity type.
+ *
+ * @group jsonapi
+ */
+class ContentLanguageSettingsTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['language', 'node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'language_content_settings';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'language_content_settings--language_content_settings';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\language\ContentLanguageSettingsInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer languages']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+    $camelids->save();
+
+    $entity = ContentLanguageSettings::create([
+      'target_entity_type_id' => 'node',
+      'target_bundle' => 'camelids',
+    ]);
+    $entity->setDefaultLangcode('site_default')
+      ->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/language_content_settings/language_content_settings/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'language_content_settings--language_content_settings',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'default_langcode' => 'site_default',
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+          ],
+          'langcode' => 'en',
+          'language_alterable' => FALSE,
+          'status' => TRUE,
+          'target_bundle' => 'camelids',
+          'target_entity_type_id' => 'node',
+          'drupal_internal__id' => 'node.camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['languages:language_interface']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    NodeType::create([
+      'name' => 'Llamaids',
+      'type' => 'llamaids',
+    ])->save();
+
+    $entity = ContentLanguageSettings::create([
+      'target_entity_type_id' => 'node',
+      'target_bundle' => 'llamaids',
+    ]);
+    $entity->setDefaultLangcode('site_default');
+    $entity->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
+    if (static::entityAccess(reset($collection), 'view', $account)->isAllowed()) {
+      $cacheability->addCacheContexts(['languages:language_interface']);
+    }
+    return $cacheability;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/DateFormatTest.php b/core/modules/jsonapi/tests/src/Functional/DateFormatTest.php
new file mode 100644
index 0000000000..5c1dd997c4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/DateFormatTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "DateFormat" config entity type.
+ *
+ * @group jsonapi
+ */
+class DateFormatTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'date_format';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'date_format--date_format';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $anonymousUsersCanViewLabels = TRUE;
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Datetime\DateFormatInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer site configuration']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a date format.
+    $date_format = DateFormat::create([
+      'id' => 'llama',
+      'label' => 'Llama',
+      'pattern' => 'F d, Y',
+    ]);
+
+    $date_format->save();
+
+    return $date_format;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/date_format/date_format/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'date_format--date_format',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'label' => 'Llama',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'pattern' => 'F d, Y',
+          'status' => TRUE,
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EditorTest.php b/core/modules/jsonapi/tests/src/Functional/EditorTest.php
new file mode 100644
index 0000000000..ab6f727f06
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EditorTest.php
@@ -0,0 +1,248 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\editor\Entity\Editor;
+use Drupal\filter\Entity\FilterFormat;
+
+/**
+ * JSON:API integration test for the "Editor" config entity type.
+ *
+ * @group jsonapi
+ */
+class EditorTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['filter', 'editor', 'ckeditor'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'editor';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'editor--editor';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\editor\EditorInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer filters']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Llama" filter format.
+    $llama_format = FilterFormat::create([
+      'name' => 'Llama',
+      'format' => 'llama',
+      'langcode' => 'es',
+      'filters' => [
+        'filter_html' => [
+          'status' => TRUE,
+          'settings' => [
+            'allowed_html' => '<p> <a> <b> <lo>',
+          ],
+        ],
+      ],
+    ]);
+
+    $llama_format->save();
+
+    // Create a "Camelids" editor.
+    $camelids = Editor::create([
+      'format' => 'llama',
+      'editor' => 'ckeditor',
+    ]);
+    $camelids
+      ->setImageUploadSettings([
+        'status' => FALSE,
+        'scheme' => file_default_scheme(),
+        'directory' => 'inline-images',
+        'max_size' => '',
+        'max_dimensions' => [
+          'width' => '',
+          'height' => '',
+        ],
+      ])
+      ->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/editor/editor/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'editor--editor',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [
+            'config' => [
+              'filter.format.llama',
+            ],
+            'module' => [
+              'ckeditor',
+            ],
+          ],
+          'editor' => 'ckeditor',
+          'image_upload' => [
+            'status' => FALSE,
+            'scheme' => 'public',
+            'directory' => 'inline-images',
+            'max_size' => '',
+            'max_dimensions' => [
+              'width' => NULL,
+              'height' => NULL,
+            ],
+          ],
+          'langcode' => 'en',
+          'settings' => [
+            'toolbar' => [
+              'rows' => [
+                [
+                  [
+                    'name' => 'Formatting',
+                    'items' => [
+                      'Bold',
+                      'Italic',
+                    ],
+                  ],
+                  [
+                    'name' => 'Links',
+                    'items' => [
+                      'DrupalLink',
+                      'DrupalUnlink',
+                    ],
+                  ],
+                  [
+                    'name' => 'Lists',
+                    'items' => [
+                      'BulletedList',
+                      'NumberedList',
+                    ],
+                  ],
+                  [
+                    'name' => 'Media',
+                    'items' => [
+                      'Blockquote',
+                      'DrupalImage',
+                    ],
+                  ],
+                  [
+                    'name' => 'Tools',
+                    'items' => [
+                      'Source',
+                    ],
+                  ],
+                ],
+              ],
+            ],
+            'plugins' => [
+              'language' => [
+                'language_list' => 'un',
+              ],
+            ],
+          ],
+          'status' => TRUE,
+          'drupal_internal__format' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer filters' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    FilterFormat::create([
+      'name' => 'Pachyderm',
+      'format' => 'pachyderm',
+      'langcode' => 'fr',
+      'filters' => [
+        'filter_html' => [
+          'status' => TRUE,
+          'settings' => [
+            'allowed_html' => '<p> <a> <b> <lo>',
+          ],
+        ],
+      ],
+    ])->save();
+
+    $entity = Editor::create([
+      'format' => 'pachyderm',
+      'editor' => 'ckeditor',
+    ]);
+
+    $entity->setImageUploadSettings([
+      'status' => FALSE,
+      'scheme' => file_default_scheme(),
+      'directory' => 'inline-images',
+      'max_size' => '',
+      'max_dimensions' => [
+        'width' => '',
+        'height' => '',
+      ],
+    ])->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // Also reset the 'filter_format' entity access control handler because
+    // editor access also depends on access to the configured filter format.
+    \Drupal::entityTypeManager()->getAccessControlHandler('filter_format')->resetCache();
+    return parent::entityAccess($entity, $operation, $account);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityFormDisplayTest.php b/core/modules/jsonapi/tests/src/Functional/EntityFormDisplayTest.php
new file mode 100644
index 0000000000..b28efdb8fb
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityFormDisplayTest.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\Core\Url;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for the "EntityFormDisplay" config entity type.
+ *
+ * @group jsonapi
+ */
+class EntityFormDisplayTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_form_display';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_form_display--entity_form_display';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Entity\Display\EntityFormDisplayInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer node form display']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+    $camelids->save();
+
+    // Create a form display.
+    $form_display = EntityFormDisplay::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'camelids',
+      'mode' => 'default',
+    ]);
+    $form_display->save();
+
+    return $form_display;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_form_display/entity_form_display/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_form_display--entity_form_display',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'content' => [
+            'created' => [
+              'type' => 'datetime_timestamp',
+              'weight' => 10,
+              'region' => 'content',
+              'settings' => [],
+              'third_party_settings' => [],
+            ],
+            'promote' => [
+              'type' => 'boolean_checkbox',
+              'settings' => [
+                'display_label' => TRUE,
+              ],
+              'weight' => 15,
+              'region' => 'content',
+              'third_party_settings' => [],
+            ],
+            'status' => [
+              'type' => 'boolean_checkbox',
+              'weight' => 120,
+              'region' => 'content',
+              'settings' => [
+                'display_label' => TRUE,
+              ],
+              'third_party_settings' => [],
+            ],
+            'sticky' => [
+              'type' => 'boolean_checkbox',
+              'settings' => [
+                'display_label' => TRUE,
+              ],
+              'weight' => 16,
+              'region' => 'content',
+              'third_party_settings' => [],
+            ],
+            'title' => [
+              'type' => 'string_textfield',
+              'weight' => -5,
+              'region' => 'content',
+              'settings' => [
+                'size' => 60,
+                'placeholder' => '',
+              ],
+              'third_party_settings' => [],
+            ],
+            'uid' => [
+              'type' => 'entity_reference_autocomplete',
+              'weight' => 5,
+              'settings' => [
+                'match_operator' => 'CONTAINS',
+                'size' => 60,
+                'placeholder' => '',
+              ],
+              'region' => 'content',
+              'third_party_settings' => [],
+            ],
+          ],
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+          ],
+          'hidden' => [],
+          'langcode' => 'en',
+          'mode' => 'default',
+          'status' => NULL,
+          'targetEntityType' => 'node',
+          'drupal_internal__id' => 'node.camelids.default',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer node form display' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    NodeType::create([
+      'name' => 'Llamaids',
+      'type' => 'llamaids',
+    ])->save();
+
+    $entity = EntityFormDisplay::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'llamaids',
+      'mode' => 'default',
+    ]);
+    $entity->save();
+
+    return $entity;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityFormModeTest.php b/core/modules/jsonapi/tests/src/Functional/EntityFormModeTest.php
new file mode 100644
index 0000000000..9a1c36d753
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityFormModeTest.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\Entity\EntityFormMode;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "EntityFormMode" config entity type.
+ *
+ * @group jsonapi
+ */
+class EntityFormModeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo: Remove 'field_ui' when https://www.drupal.org/node/2867266.
+   */
+  public static $modules = ['user', 'field_ui'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_form_mode';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_form_mode--entity_form_mode';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Entity\EntityFormModeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer display modes']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity_form_mode = EntityFormMode::create([
+      'id' => 'user.test',
+      'label' => 'Test',
+      'targetEntityType' => 'user',
+    ]);
+    $entity_form_mode->save();
+    return $entity_form_mode;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_form_mode/entity_form_mode/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_form_mode--entity_form_mode',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'cache' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'user',
+            ],
+          ],
+          'label' => 'Test',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'user',
+          'drupal_internal__id' => 'user.test',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityTestMapFieldTest.php b/core/modules/jsonapi/tests/src/Functional/EntityTestMapFieldTest.php
new file mode 100644
index 0000000000..b78f96cd78
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityTestMapFieldTest.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTestMapField;
+use Drupal\user\Entity\User;
+
+/**
+ * JSON:API integration test for the "EntityTestMapField" content entity type.
+ *
+ * @group jsonapi
+ */
+class EntityTestMapFieldTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_test_map_field';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_test_map_field--entity_test_map_field';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\entity_test\Entity\EntityTestMapField
+   */
+  protected $entity;
+
+  /**
+   * The complex nested value to assign to a @FieldType=map field.
+   *
+   * @var array
+   */
+  protected static $mapValue = [
+    'key1' => 'value',
+    'key2' => 'no, val you',
+    'π' => 3.14159,
+    TRUE => 42,
+    'nested' => [
+      'bird' => 'robin',
+      'doll' => 'Russian',
+    ],
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer entity_test content']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity = EntityTestMapField::create([
+      'name' => 'Llama',
+      'type' => 'entity_test_map_field',
+      'data' => [
+        static::$mapValue,
+      ],
+    ]);
+    $entity->setOwnerId(0);
+    $entity->save();
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_test_map_field/entity_test_map_field/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $author = User::load(0);
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_test_map_field--entity_test_map_field',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => (new \DateTime())->setTimestamp($this->entity->get('created')->value)->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'data' => static::$mapValue,
+          'drupal_internal__id' => 1,
+        ],
+        'relationships' => [
+          'user_id' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/user_id'],
+              'self' => ['href' => $self_url . '/relationships/user_id'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'entity_test_map_field--entity_test_map_field',
+        'attributes' => [
+          'name' => 'Dramallama',
+          'data' => static::$mapValue,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer entity_test content' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSparseFieldSets() {
+    // EntityTestMapField's owner field name is `user_id`, not `uid`, which
+    // breaks nested sparse fieldset tests.
+    return array_diff_key(parent::getSparseFieldSets(), array_flip([
+      'nested_empty_fieldset',
+      'nested_fieldset_with_owner_fieldset',
+    ]));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityTestTest.php b/core/modules/jsonapi/tests/src/Functional/EntityTestTest.php
new file mode 100644
index 0000000000..a4605068c4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityTestTest.php
@@ -0,0 +1,193 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * JSON:API integration test for the "EntityTest" content entity type.
+ *
+ * @group jsonapi
+ */
+class EntityTestTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_test--entity_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\entity_test\Entity\EntityTest
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view test entity']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities']);
+        break;
+
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer entity_test content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Set flag so that internal field 'internal_string_field' is created.
+    // @see entity_test_entity_base_field_info()
+    $this->container->get('state')->set('entity_test.internal_field', TRUE);
+    \Drupal::entityDefinitionUpdateManager()->applyUpdates();
+
+    $entity_test = EntityTest::create([
+      'name' => 'Llama',
+      'type' => 'entity_test',
+      // Set a value for the internal field to confirm that it will not be
+      // returned in normalization.
+      // @see entity_test_entity_base_field_info().
+      'internal_string_field' => [
+        'value' => 'This value shall not be internal!',
+      ],
+    ]);
+    $entity_test->setOwnerId(0);
+    $entity_test->save();
+
+    return $entity_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $author = User::load(0);
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_test--entity_test',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => (new \DateTime())->setTimestamp($this->entity->get('created')->value)->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'field_test_text' => NULL,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'entity_test_type' => 'entity_test',
+          'drupal_internal__id' => 1,
+        ],
+        'relationships' => [
+          'user_id' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/user_id'],
+              'self' => ['href' => $self_url . '/relationships/user_id'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'entity_test--entity_test',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'view test entity' permission is required.";
+
+      case 'POST':
+        return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSparseFieldSets() {
+    // EntityTest's owner field name is `user_id`, not `uid`, which breaks
+    // nested sparse fieldset tests.
+    return array_diff_key(parent::getSparseFieldSets(), array_flip([
+      'nested_empty_fieldset',
+      'nested_fieldset_with_owner_fieldset',
+    ]));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
+    if ($filtered) {
+      $cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
+    }
+    return $cacheability;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityViewDisplayTest.php b/core/modules/jsonapi/tests/src/Functional/EntityViewDisplayTest.php
new file mode 100644
index 0000000000..1b154cc10e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityViewDisplayTest.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\Entity\EntityViewDisplay;
+use Drupal\Core\Url;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for the "EntityViewDisplay" config entity type.
+ *
+ * @group jsonapi
+ */
+class EntityViewDisplayTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_view_display';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_view_display--entity_view_display';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer node display']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+    $camelids->save();
+
+    // Create a view display.
+    $view_display = EntityViewDisplay::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'camelids',
+      'mode' => 'default',
+      'status' => TRUE,
+    ]);
+    $view_display->save();
+
+    return $view_display;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_view_display/entity_view_display/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_view_display--entity_view_display',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'content' => [
+            'links' => [
+              'region' => 'content',
+              'weight' => 100,
+              'settings' => [],
+              'third_party_settings' => [],
+            ],
+          ],
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+            'module' => [
+              'user',
+            ],
+          ],
+          'hidden' => [],
+          'langcode' => 'en',
+          'mode' => 'default',
+          'status' => TRUE,
+          'targetEntityType' => 'node',
+          'drupal_internal__id' => 'node.camelids.default',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer node display' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    NodeType::create([
+      'name' => 'Pachyderms',
+      'type' => 'pachyderms',
+    ])->save();
+
+    $entity = EntityViewDisplay::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'pachyderms',
+      'mode' => 'default',
+      'status' => TRUE,
+    ]);
+    $entity->save();
+
+    return $entity;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityViewModeTest.php b/core/modules/jsonapi/tests/src/Functional/EntityViewModeTest.php
new file mode 100644
index 0000000000..991b35fc93
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityViewModeTest.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\Entity\EntityViewMode;
+use Drupal\Core\Url;
+
+/**
+ * JSON:API integration test for the "EntityViewMode" config entity type.
+ *
+ * @group jsonapi
+ */
+class EntityViewModeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo: Remove 'field_ui' when https://www.drupal.org/node/2867266.
+   */
+  public static $modules = ['user', 'field_ui'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_view_mode';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'entity_view_mode--entity_view_mode';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\Core\Entity\EntityViewModeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer display modes']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity_view_mode = EntityViewMode::create([
+      'id' => 'user.test',
+      'label' => 'Test',
+      'targetEntityType' => 'user',
+    ]);
+    $entity_view_mode->save();
+    return $entity_view_mode;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_view_mode/entity_view_mode/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_view_mode--entity_view_mode',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'cache' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'user',
+            ],
+          ],
+          'label' => 'Test',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'user',
+          'drupal_internal__id' => 'user.test',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php b/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php
new file mode 100644
index 0000000000..2934ba0666
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntryPointTest.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * Makes assertions about the JSON:API behavior for internal entities.
+ *
+ * @group jsonapi
+ *
+ * @internal
+ */
+class EntryPointTest extends BrowserTestBase {
+
+  use JsonApiRequestTestTrait;
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'jsonapi',
+    'basic_auth',
+  ];
+
+  /**
+   * Test GETing the entry point.
+   */
+  public function testEntryPoint() {
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $response = $this->request('GET', Url::fromUri('base://jsonapi'), $request_options);
+    $document = Json::decode((string) $response->getBody());
+    $expected_cache_contexts = [
+      'url.site',
+      'user.roles:authenticated',
+    ];
+    $this->assertTrue($response->hasHeader('X-Drupal-Cache-Contexts'));
+    $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
+    $this->assertSame($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
+    $links = $document['links'];
+    $this->assertRegExp('/.*\/jsonapi/', $links['self']['href']);
+    $this->assertRegExp('/.*\/jsonapi\/user\/user/', $links['user--user']['href']);
+    $this->assertRegExp('/.*\/jsonapi\/node_type\/node_type/', $links['node_type--node_type']['href']);
+    $this->assertArrayNotHasKey('meta', $document);
+
+    // A `me` link must be present for authenticated users.
+    $user = $this->createUser();
+    $request_options[RequestOptions::HEADERS]['Authorization'] = 'Basic ' . base64_encode($user->name->value . ':' . $user->passRaw);
+    $response = $this->request('GET', Url::fromUri('base://jsonapi'), $request_options);
+    $document = Json::decode((string) $response->getBody());
+    $this->assertArrayHasKey('meta', $document);
+    $this->assertStringEndsWith('/jsonapi/user/user/' . $user->uuid(), $document['meta']['links']['me']['href']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php b/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php
new file mode 100644
index 0000000000..e520a60b3c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php
@@ -0,0 +1,197 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * Asserts external normalizers are handled as expected by the JSON:API module.
+ *
+ * @see jsonapi.normalizers
+ *
+ * @group jsonapi
+ */
+class ExternalNormalizersTest extends BrowserTestBase {
+
+  /**
+   * The original value for the test field.
+   *
+   * @var string
+   */
+  const VALUE_ORIGINAL = 'Llamas are super awesome!';
+
+  /**
+   * The expected overridden value for the test field.
+   *
+   * @see \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer
+   * @see \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer
+   */
+  const VALUE_OVERRIDDEN = 'Llamas are NOT awesome!';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'jsonapi',
+    'entity_test',
+  ];
+
+  /**
+   * The test entity.
+   *
+   * @var \Drupal\entity_test\Entity\EntityTest
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    // This test is not about access control at all, so allow anonymous users to
+    // view and create the test entities.
+    Role::load(RoleInterface::ANONYMOUS_ID)
+      ->grantPermission('view test entity')
+      ->grantPermission('create entity_test entity_test_with_bundle entities')
+      ->save();
+
+    FieldStorageConfig::create([
+      'field_name' => 'field_test',
+      'type' => 'string',
+      'entity_type' => 'entity_test',
+    ])
+      ->save();
+    FieldConfig::create([
+      'field_name' => 'field_test',
+      'entity_type' => 'entity_test',
+      'bundle' => 'entity_test',
+    ])
+      ->save();
+
+    $this->entity = EntityTest::create([
+      'name' => 'Llama',
+      'type' => 'entity_test',
+      'field_test' => static::VALUE_ORIGINAL,
+    ]);
+    $this->entity->save();
+  }
+
+  /**
+   * Tests a format-agnostic normalizer.
+   *
+   * @param string $test_module
+   *   The test module to install, which comes with a high-priority normalizer.
+   * @param string $expected_value_jsonapi_normalization
+   *   The expected JSON:API normalization of the tested field. Must be either
+   *   - static::VALUE_ORIGINAL (normalizer IS NOT expected to override)
+   *   - static::VALUE_OVERRIDDEN (normalizer IS expected to override)
+   * @param string $expected_value_jsonapi_denormalization
+   *   The expected JSON:API denormalization of the tested field. Must be either
+   *   - static::VALUE_OVERRIDDEN (denormalizer IS NOT expected to override)
+   *   - static::VALUE_ORIGINAL (denormalizer IS expected to override)
+   *
+   * @dataProvider providerTestFormatAgnosticNormalizers
+   */
+  public function testFormatAgnosticNormalizers($test_module, $expected_value_jsonapi_normalization, $expected_value_jsonapi_denormalization) {
+    assert(in_array($expected_value_jsonapi_normalization, [static::VALUE_ORIGINAL, static::VALUE_OVERRIDDEN], TRUE));
+    assert(in_array($expected_value_jsonapi_denormalization, [static::VALUE_ORIGINAL, static::VALUE_OVERRIDDEN], TRUE));
+
+    // Asserts the entity contains the value we set.
+    $this->assertSame(static::VALUE_ORIGINAL, $this->entity->field_test->value);
+
+    // Asserts normalizing the entity using core's 'serializer' service DOES
+    // yield the value we set.
+    $core_normalization = $this->container->get('serializer')->normalize($this->entity);
+    $this->assertSame(static::VALUE_ORIGINAL, $core_normalization['field_test'][0]['value']);
+
+    // Asserts denormalizing the entity using core's 'serializer' service DOES
+    // yield the value we set.
+    $core_normalization['field_test'][0]['value'] = static::VALUE_OVERRIDDEN;
+    $denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTest::class, 'json', []);
+    $this->assertInstanceOf(EntityTest::class, $denormalized_entity);
+    $this->assertSame(static::VALUE_OVERRIDDEN, $denormalized_entity->field_test->value);
+
+    // Install test module that contains a high-priority alternative normalizer.
+    $this->container->get('module_installer')->install([$test_module]);
+    $this->rebuildContainer();
+
+    // Asserts normalizing the entity using core's 'serializer' service DOES NOT
+    // ANYMORE yield the value we set.
+    $core_normalization = $this->container->get('serializer')->normalize($this->entity);
+    $this->assertSame(static::VALUE_OVERRIDDEN, $core_normalization['field_test'][0]['value']);
+
+    // Asserts denormalizing the entity using core's 'serializer' service DOES
+    // NOT ANYMORE yield the value we set.
+    $core_normalization = $this->container->get('serializer')->normalize($this->entity);
+    $core_normalization['field_test'][0]['value'] = static::VALUE_OVERRIDDEN;
+    $denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTest::class, 'json', []);
+    $this->assertInstanceOf(EntityTest::class, $denormalized_entity);
+    $this->assertSame(static::VALUE_ORIGINAL, $denormalized_entity->field_test->value);
+
+    // Asserts the expected JSON:API normalization.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute('jsonapi.entity_test--entity_test.individual', ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $client = $this->getSession()->getDriver()->getClient()->getClient();
+    $response = $client->request('GET', $url->setAbsolute(TRUE)->toString());
+    $document = Json::decode((string) $response->getBody());
+    $this->assertSame($expected_value_jsonapi_normalization, $document['data']['attributes']['field_test']);
+
+    // Asserts the expected JSON:API denormalization.
+    $request_options = [];
+    $request_options[RequestOptions::BODY] = Json::encode([
+      'data' => [
+        'type' => 'entity_test--entity_test',
+        'attributes' => [
+          'field_test' => static::VALUE_OVERRIDDEN,
+        ],
+      ],
+    ]);
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $response = $client->request('POST', Url::fromRoute('jsonapi.entity_test--entity_test.collection.post')->setAbsolute(TRUE)->toString(), $request_options);
+    $document = Json::decode((string) $response->getBody());
+    $this->assertSame(static::VALUE_OVERRIDDEN, $document['data']['attributes']['field_test']);
+    $entity_type_manager = $this->container->get('entity_type.manager');
+    $uuid_key = $entity_type_manager->getDefinition('entity_test')->getKey('uuid');
+    $entities = $entity_type_manager
+      ->getStorage('entity_test')
+      ->loadByProperties([$uuid_key => $document['data']['id']]);
+    $created_entity = reset($entities);
+    $this->assertSame($expected_value_jsonapi_denormalization, $created_entity->field_test->value);
+  }
+
+  /**
+   * Data provider.
+   *
+   * @return array
+   *   Test cases.
+   */
+  public function providerTestFormatAgnosticNormalizers() {
+    return [
+      'Format-agnostic @FieldType-level normalizers SHOULD NOT be able to affect the JSON:API normalization' => [
+        'jsonapi_test_field_type',
+        // \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer::normalize()
+        static::VALUE_ORIGINAL,
+        // \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer::denormalize()
+        static::VALUE_OVERRIDDEN,
+      ],
+      'Format-agnostic @DataType-level normalizers SHOULD be able to affect the JSON:API normalization' => [
+        'jsonapi_test_data_type',
+        // \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer::normalize()
+        static::VALUE_OVERRIDDEN,
+        // \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer::denormalize()
+        static::VALUE_ORIGINAL,
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FeedTest.php b/core/modules/jsonapi/tests/src/Functional/FeedTest.php
new file mode 100644
index 0000000000..5ca4f906f6
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FeedTest.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\aggregator\Entity\Feed;
+use Drupal\Core\Url;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+
+/**
+ * JSON:API integration test for the "Feed" content entity type.
+ *
+ * @group jsonapi
+ */
+class FeedTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['aggregator'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'aggregator_feed';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'aggregator_feed--aggregator_feed';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\config_test\ConfigTestInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $uniqueFieldNames = ['url'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access news feeds']);
+        break;
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer news feeds']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createEntity() {
+    $feed = Feed::create();
+    $feed->set('fid', 1)
+      ->setTitle('Feed')
+      ->setUrl('http://example.com/rss.xml')
+      ->setDescription('Feed Resource Test 1')
+      ->setRefreshRate(900)
+      ->setLastCheckedTime(123456789)
+      ->setQueuedTime(123456789)
+      ->setWebsiteUrl('http://example.com')
+      ->setImage('http://example.com/feed_logo')
+      ->setHash('abcdefg')
+      ->setEtag('hijklmn')
+      ->setLastModified(123456789)
+      ->save();
+
+    return $feed;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    /* @var \Drupal\aggregator\FeedInterface $duplicate */
+    $duplicate = $this->getEntityDuplicate($this->entity, $key);
+    $duplicate->set('field_rest_test', 'Duplicate feed entity');
+    $duplicate->setUrl("http://example.com/$key.xml");
+    $duplicate->save();
+    return $duplicate;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/aggregator_feed/aggregator_feed/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'aggregator_feed--aggregator_feed',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'url' => 'http://example.com/rss.xml',
+          'title' => 'Feed',
+          'refresh' => 900,
+          'checked' => '1973-11-29T21:33:09+00:00',
+          'queued' => '1973-11-29T21:33:09+00:00',
+          'link' => 'http://example.com',
+          'description' => 'Feed Resource Test 1',
+          'image' => 'http://example.com/feed_logo',
+          'hash' => 'abcdefg',
+          'etag' => 'hijklmn',
+          'modified' => '1973-11-29T21:33:09+00:00',
+          'langcode' => 'en',
+          'drupal_internal__fid' => 1,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'aggregator_feed--aggregator_feed',
+        'attributes' => [
+          'title' => 'Feed Resource Post Test',
+          'url' => 'http://example.com/feed',
+          'refresh' => 900,
+          'description' => 'Feed Resource Post Test Description',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access news feeds' permission is required.";
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        return "The 'administer news feeds' permission is required.";
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $this->doTestCollectionFilterAccessBasedOnPermissions('title', 'access news feeds');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php b/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php
new file mode 100644
index 0000000000..311c098e43
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for the "FieldConfig" config entity type.
+ *
+ * @group jsonapi
+ */
+class FieldConfigTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['field', 'node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'field_config';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'field_config--field_config';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\field\FieldConfigInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer node fields']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+    $camelids->save();
+
+    $field_storage = FieldStorageConfig::create([
+      'field_name' => 'field_llama',
+      'entity_type' => 'node',
+      'type' => 'text',
+    ]);
+    $field_storage->save();
+
+    $entity = FieldConfig::create([
+      'field_storage' => $field_storage,
+      'bundle' => 'camelids',
+    ]);
+    $entity->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/field_config/field_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'field_config--field_config',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'default_value' => [],
+          'default_value_callback' => '',
+          'dependencies' => [
+            'config' => [
+              'field.storage.node.field_llama',
+              'node.type.camelids',
+            ],
+            'module' => [
+              'text',
+            ],
+          ],
+          'description' => '',
+          'entity_type' => 'node',
+          'field_name' => 'field_llama',
+          'field_type' => 'text',
+          'label' => 'field_llama',
+          'langcode' => 'en',
+          'required' => FALSE,
+          'settings' => [],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'drupal_internal__id' => 'node.camelids.field_llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer node fields' permission is required.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    NodeType::create([
+      'name' => 'Pachyderms',
+      'type' => 'pachyderms',
+    ])->save();
+
+    $field_storage = FieldStorageConfig::create([
+      'field_name' => 'field_pachyderm',
+      'entity_type' => 'node',
+      'type' => 'text',
+    ]);
+    $field_storage->save();
+
+    $entity = FieldConfig::create([
+      'field_storage' => $field_storage,
+      'bundle' => 'pachyderms',
+    ]);
+    $entity->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // Also clear the 'field_storage_config' entity access handler cache because
+    // the 'field_config' access handler delegates access to it.
+    // @see \Drupal\field\FieldConfigAccessControlHandler::checkAccess()
+    \Drupal::entityTypeManager()->getAccessControlHandler('field_storage_config')->resetCache();
+    return parent::entityAccess($entity, $operation, $account);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FieldStorageConfigTest.php b/core/modules/jsonapi/tests/src/Functional/FieldStorageConfigTest.php
new file mode 100644
index 0000000000..0f75906bbb
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FieldStorageConfigTest.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldStorageConfig;
+
+/**
+ * JSON:API integration test for the "FieldStorageConfig" config entity type.
+ *
+ * @group jsonapi
+ */
+class FieldStorageConfigTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'field_storage_config';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'field_storage_config--field_storage_config';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\field\FieldConfigStorage
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer node fields']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $field_storage = FieldStorageConfig::create([
+      'field_name' => 'true_llama',
+      'entity_type' => 'node',
+      'type' => 'boolean',
+    ]);
+    $field_storage->save();
+    return $field_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/field_storage_config/field_storage_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'field_storage_config--field_storage_config',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'cardinality' => 1,
+          'custom_storage' => FALSE,
+          'dependencies' => [
+            'module' => [
+              'node',
+            ],
+          ],
+          'entity_type' => 'node',
+          'field_name' => 'true_llama',
+          'indexes' => [],
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'module' => 'core',
+          'persist_with_no_fields' => FALSE,
+          'settings' => [],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'field_storage_config_type' => 'boolean',
+          'drupal_internal__id' => 'node.true_llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'administer node fields' permission is required.";
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FileTest.php b/core/modules/jsonapi/tests/src/Functional/FileTest.php
new file mode 100644
index 0000000000..ff674174ed
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FileTest.php
@@ -0,0 +1,244 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Url;
+use Drupal\file\Entity\File;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "File" content entity type.
+ *
+ * @group jsonapi
+ */
+class FileTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['file', 'user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'file';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'file--file';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\file\FileInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'uri' => NULL,
+    'filemime' => NULL,
+    'filesize' => NULL,
+    'status' => NULL,
+    'changed' => NULL,
+  ];
+
+  /**
+   * The file author.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $author;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'PATCH':
+      case 'DELETE':
+        // \Drupal\file\FileAccessControlHandler::checkAccess() grants 'update'
+        // and 'delete' access only to the user that owns the file. So there is
+        // no permission to grant: instead, the file owner must be changed from
+        // its default (user 1) to the current user.
+        $this->makeCurrentUserFileOwner();
+        break;
+    }
+  }
+
+  /**
+   * Makes the current user the file owner.
+   */
+  protected function makeCurrentUserFileOwner() {
+    $account = User::load(2);
+    $this->entity->setOwnerId($account->id());
+    $this->entity->setOwner($account);
+    $this->entity->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $this->author = User::load(1);
+
+    $file = File::create();
+    $file->setOwnerId($this->author->id());
+    $file->setFilename('drupal.txt');
+    $file->setMimeType('text/plain');
+    $file->setFileUri('public://drupal.txt');
+    $file->set('status', FILE_STATUS_PERMANENT);
+    $file->save();
+
+    file_put_contents($file->getFileUri(), 'Drupal');
+
+    return $file;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    /* @var \Drupal\file\FileInterface $duplicate */
+    $duplicate = parent::createAnotherEntity($key);
+    $duplicate->setFileUri("public://$key.txt");
+    $duplicate->save();
+    return $duplicate;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/file/file/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'file--file',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => (new \DateTime())->setTimestamp($this->entity->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'filemime' => 'text/plain',
+          'filename' => 'drupal.txt',
+          'filesize' => (int) $this->entity->getSize(),
+          'langcode' => 'en',
+          'status' => TRUE,
+          'uri' => [
+            'url' => base_path() . $this->siteDirectory . '/files/drupal.txt',
+            'value' => 'public://drupal.txt',
+          ],
+          'drupal_internal__fid' => 1,
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $this->author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/uid'],
+              'self' => ['href' => $self_url . '/relationships/uid'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'file--file',
+        'attributes' => [
+          'filename' => 'drupal.txt',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    // @todo https://www.drupal.org/node/1927648
+    $this->markTestSkipped();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($method === 'GET') {
+      return "The 'access content' permission is required.";
+    }
+    if ($method === 'PATCH' || $method === 'DELETE') {
+      return "Only the file owner can update or delete the file entity.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $label_field_name = 'filename';
+    // Verify the expected behavior in the common case: when the file is public.
+    $this->doTestCollectionFilterAccessBasedOnPermissions($label_field_name, 'access content');
+
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    $collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // 1 result because the current user is the file owner, even though the file
+    // is private.
+    $this->entity->setFileUri('private://drupal.txt');
+    $this->entity->setOwner($this->account);
+    $this->entity->save();
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+
+    // 0 results because the current user is no longer the file owner and the
+    // file is private.
+    $this->entity->setOwner(User::load(0));
+    $this->entity->save();
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
new file mode 100644
index 0000000000..1c380aaee2
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
@@ -0,0 +1,806 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\file\Entity\File;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Tests binary data file upload route.
+ *
+ * @group jsonapi
+ */
+class FileUploadTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['entity_test', 'file'];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see $entity
+   */
+  protected static $entityTypeId = 'entity_test';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see $entity
+   */
+  protected static $resourceTypeName = 'entity_test--entity_test';
+
+  /**
+   * The POST URI.
+   *
+   * @var string
+   */
+  protected static $postUri = '/jsonapi/entity_test/entity_test/field_rest_file_test';
+
+  /**
+   * Test file data.
+   *
+   * @var string
+   */
+  protected $testFileData = 'Hares sit on chairs, and mules sit on stools.';
+
+  /**
+   * The test field storage config.
+   *
+   * @var \Drupal\field\Entity\FieldStorageConfig
+   */
+  protected $fieldStorage;
+
+  /**
+   * The field config.
+   *
+   * @var \Drupal\field\Entity\FieldConfig
+   */
+  protected $field;
+
+  /**
+   * The parent entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * Created file entity.
+   *
+   * @var \Drupal\file\Entity\File
+   */
+  protected $file;
+
+  /**
+   * An authenticated user.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $user;
+
+  /**
+   * The entity storage for the 'file' entity type.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $fileStorage;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->fileStorage = $this->container->get('entity_type.manager')
+      ->getStorage('file');
+
+    // Add a file field.
+    $this->fieldStorage = FieldStorageConfig::create([
+      'entity_type' => 'entity_test',
+      'field_name' => 'field_rest_file_test',
+      'type' => 'file',
+      'settings' => [
+        'uri_scheme' => 'public',
+      ],
+    ])
+      ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    $this->fieldStorage->save();
+
+    $this->field = FieldConfig::create([
+      'entity_type' => 'entity_test',
+      'field_name' => 'field_rest_file_test',
+      'bundle' => 'entity_test',
+      'settings' => [
+        'file_directory' => 'foobar',
+        'file_extensions' => 'txt',
+        'max_filesize' => '',
+      ],
+    ])
+      ->setLabel('Test file field')
+      ->setTranslatable(FALSE);
+    $this->field->save();
+
+    // Reload entity so that it has the new field.
+    $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
+
+    $this->rebuildAll();
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testGetIndividual() {}
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testPostIndividual() {}
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testPatchIndividual() {}
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testDeleteIndividual() {}
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testCollection() {}
+
+  /**
+   * {@inheritdoc}
+   *
+   * @requires module irrelevant_for_this_test
+   */
+  public function testRelationships() {}
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create an entity that a file can be attached to.
+    $entity_test = EntityTest::create([
+      'name' => 'Llama',
+      'type' => 'entity_test',
+    ]);
+    $entity_test->setOwnerId($this->account->id());
+    $entity_test->save();
+
+    return $entity_test;
+  }
+
+  /**
+   * Tests using the file upload POST route; needs second request to "use" file.
+   */
+  public function testPostFileUpload() {
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // DX: 403 when unauthorized.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $uri, $response);
+
+    $this->setUpAuthorization('POST');
+
+    // 404 when the field name is invalid.
+    $invalid_uri = Url::fromUri('base:' . static::$postUri . '_invalid');
+    $response = $this->fileRequest($invalid_uri, $this->testFileData);
+    $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $response);
+
+    // This request will have the default 'application/octet-stream' content
+    // type header.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument();
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
+
+    // Test the file again but using 'filename' in the Content-Disposition
+    // header with no 'file' prefix.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument(2, 'example_0.txt');
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
+    $this->assertTrue($this->fileStorage->loadUnchanged(1)->isTemporary());
+
+    // Verify that we can create an entity that references the uploaded file.
+    $entity_test_post_url = Url::fromRoute('jsonapi.entity_test--entity_test.collection.post');
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    $request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument());
+    $response = $this->request('POST', $entity_test_post_url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertTrue($this->fileStorage->loadUnchanged(1)->isPermanent());
+    $this->assertSame([
+      [
+        'target_id' => '1',
+        'display' => NULL,
+        'description' => "The most fascinating file ever!",
+      ],
+    ], EntityTest::load(2)->get('field_rest_file_test')->getValue());
+  }
+
+  /**
+   * Tests using the 'file upload and "use" file in single request" POST route.
+   */
+  public function testPostFileUploadAndUseInSingleRequest() {
+    // Update the test entity so it already has a file. This allows verifying
+    // that this route appends files, and does not replace them.
+    mkdir('public://foobar');
+    file_put_contents('public://foobar/existing.txt', $this->testFileData);
+    $existing_file = File::create([
+      'uri' => 'public://foobar/existing.txt',
+    ]);
+    $existing_file->setOwnerId($this->account->id());
+    $existing_file->setPermanent();
+    $existing_file->save();
+    $this->entity
+      ->set('field_rest_file_test', ['target_id' => $existing_file->id()])
+      ->save();
+
+    $uri = Url::fromUri('base:' . '/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test');
+
+    // DX: 403 when unauthorized.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $uri, $response);
+
+    $this->setUpAuthorization('PATCH');
+
+    // 404 when the field name is invalid.
+    $invalid_uri = Url::fromUri($uri->getUri() . '_invalid');
+    $response = $this->fileRequest($invalid_uri, $this->testFileData);
+    $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $response);
+
+    // This request fails despite the upload succeeding, because we're not
+    // allowed to view the entity we're uploading to.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.site', 'user.permissions']);
+
+    $this->setUpAuthorization('GET');
+
+    // Reuploading the same file will result in the file being uploaded twice
+    // and referenced twice.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertSame(200, $response->getStatusCode());
+    $expected = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test')->setAbsolute(TRUE)->toString()],
+      ],
+      'data' => [
+        0 => $this->getExpectedDocument(1, 'existing.txt', TRUE, TRUE)['data'],
+        1 => $this->getExpectedDocument(2, 'example.txt', TRUE, TRUE)['data'],
+        2 => $this->getExpectedDocument(3, 'example_0.txt', FALSE, TRUE)['data'],
+      ],
+    ];
+    $this->assertResponseData($expected, $response);
+
+    // The response document received for the POST request is identical to the
+    // response document received by GETting the same URL.
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $response = $this->request('GET', $uri, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
+  }
+
+  /**
+   * Returns the JSON:API POST document referencing the uploaded file.
+   *
+   * @return array
+   *   A JSON:API request document.
+   *
+   * @see ::testPostFileUpload()
+   * @see \Drupal\Tests\jsonapi\Functional\EntityTestTest::getPostDocument()
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'entity_test--entity_test',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+        'relationships' => [
+          'field_rest_file_test' => [
+            'data' => [
+              'id' => File::load(1)->uuid(),
+              'meta' => [
+                'description' => 'The most fascinating file ever!',
+              ],
+              'type' => 'file--file',
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests using the file upload POST route with invalid headers.
+   */
+  public function testPostFileUploadInvalidHeaders() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // The wrong content type header should return a 415 code.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => 'application/vnd.api+json']);
+    $this->assertSame(415, $response->getStatusCode());
+
+    // An empty Content-Disposition header should return a 400.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]);
+    $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.', $uri, $response);
+
+    // An empty filename with a context in the Content-Disposition header should
+    // return a 400.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response);
+
+    // An empty filename without a context in the Content-Disposition header
+    // should return a 400.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response);
+
+    // An invalid key-value pair in the Content-Disposition header should return
+    // a 400.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']);
+    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response);
+
+    // Using filename* extended format is not currently supported.
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']);
+    $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header.', $uri, $response);
+  }
+
+  /**
+   * Tests using the file upload POST route with a duplicate file name.
+   *
+   * A new file should be created with a suffixed name.
+   */
+  public function testPostFileUploadDuplicateFile() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // This request will have the default 'application/octet-stream' content
+    // type header.
+    $response = $this->fileRequest($uri, $this->testFileData);
+
+    $this->assertSame(201, $response->getStatusCode());
+
+    // Make the same request again. The file should be saved as a new file
+    // entity that has the same file name but a suffixed file URI.
+    $response = $this->fileRequest($uri, $this->testFileData);
+    $this->assertSame(201, $response->getStatusCode());
+
+    // Loading expected normalized data for file 2, the duplicate file.
+    $expected = $this->getExpectedDocument(2, 'example_0.txt');
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
+  }
+
+  /**
+   * Tests using the file upload route with any path prefixes being stripped.
+   *
+   * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives
+   */
+  public function testFileUploadStrippedFilePath() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument();
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data. It should have been written to the configured
+    // directory, not /foobar/directory/example.txt.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
+
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="../../example_2.txt"']);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument(2, 'example_2.txt', TRUE);
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data. It should have been written to the configured
+    // directory, not /foobar/directory/example.txt.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_2.txt'));
+    $this->assertFalse(file_exists('../../example_2.txt'));
+
+    // Check a path from the root. Extensions have to be empty to allow a file
+    // with no extension to pass validation.
+    $this->field->setSetting('file_extensions', '')
+      ->save();
+    $this->rebuildAll();
+
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument(3, 'passwd', TRUE);
+    // This mime will be guessed as there is no extension.
+    $expected['data']['attributes']['filemime'] = 'application/octet-stream';
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data. It should have been written to the configured
+    // directory, not /foobar/directory/example.txt.
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/passwd'));
+  }
+
+  /**
+   * Tests using the file upload route with a unicode file name.
+   */
+  public function testFileUploadUnicodeFilename() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="example-✓.txt"']);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument(1, 'example-✓.txt', TRUE);
+    $this->assertResponseData($expected, $response);
+    $this->assertSame($this->testFileData, file_get_contents('public://foobar/example-✓.txt'));
+  }
+
+  /**
+   * Tests using the file upload route with a zero byte file.
+   */
+  public function testFileUploadZeroByteFile() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // Test with a zero byte file.
+    $response = $this->fileRequest($uri, NULL);
+    $this->assertSame(201, $response->getStatusCode());
+    $expected = $this->getExpectedDocument();
+    // Modify the default expected data to account for the 0 byte file.
+    $expected['data']['attributes']['filesize'] = 0;
+    $this->assertResponseData($expected, $response);
+
+    // Check the actual file data.
+    $this->assertSame('', file_get_contents('public://foobar/example.txt'));
+  }
+
+  /**
+   * Tests using the file upload route with an invalid file type.
+   */
+  public function testFileUploadInvalidFileType() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // Test with a JSON file.
+    $response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']);
+    $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: <em class=\"placeholder\">txt</em>."), $uri, $response);
+
+    // Make sure that no file was saved.
+    $this->assertEmpty(File::load(1));
+    $this->assertFalse(file_exists('public://foobar/example.txt'));
+  }
+
+  /**
+   * Tests using the file upload route with a file size larger than allowed.
+   */
+  public function testFileUploadLargerFileSize() {
+    // Set a limit of 50 bytes.
+    $this->field->setSetting('max_filesize', 50)
+      ->save();
+    $this->rebuildAll();
+
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    // Generate a string larger than the 50 byte limit set.
+    $response = $this->fileRequest($uri, $this->randomString(100));
+    $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is <em class=\"placeholder\">100 bytes</em> exceeding the maximum file size of <em class=\"placeholder\">50 bytes</em>."), $uri, $response);
+
+    // Make sure that no file was saved.
+    $this->assertEmpty(File::load(1));
+    $this->assertFalse(file_exists('public://foobar/example.txt'));
+  }
+
+  /**
+   * Tests using the file upload POST route with malicious extensions.
+   */
+  public function testFileUploadMaliciousExtension() {
+    // Allow all file uploads but system.file::allow_insecure_uploads is set to
+    // FALSE.
+    $this->field->setSetting('file_extensions', '')->save();
+    $this->rebuildAll();
+
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    $php_string = '<?php print "Drupal"; ?>';
+
+    // Test using a masked exploit file.
+    $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']);
+    // The filename is not munged because .txt is added and it is a known
+    // extension to apache.
+    $expected = $this->getExpectedDocument(1, 'example.php.txt', TRUE);
+    // Override the expected filesize.
+    $expected['data']['attributes']['filesize'] = strlen($php_string);
+    $this->assertResponseData($expected, $response);
+    $this->assertTrue(file_exists('public://foobar/example.php.txt'));
+
+    // Add php as an allowed format. Allow insecure uploads still being FALSE
+    // should still not allow this. So it should still have a .txt extension
+    // appended even though it is not in the list of allowed extensions.
+    $this->field->setSetting('file_extensions', 'php')
+      ->save();
+    $this->rebuildAll();
+
+    $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']);
+    $expected = $this->getExpectedDocument(2, 'example_2.php.txt', TRUE);
+    // Override the expected filesize.
+    $expected['data']['attributes']['filesize'] = strlen($php_string);
+    $this->assertResponseData($expected, $response);
+    $this->assertTrue(file_exists('public://foobar/example_2.php.txt'));
+    $this->assertFalse(file_exists('public://foobar/example_2.php'));
+
+    // Allow .doc file uploads and ensure even a mis-configured apache will not
+    // fallback to php because the filename will be munged.
+    $this->field->setSetting('file_extensions', 'doc')->save();
+    $this->rebuildAll();
+
+    // Test using a masked exploit file.
+    $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_3.php.doc"']);
+    // The filename is munged.
+    $expected = $this->getExpectedDocument(3, 'example_3.php_.doc', TRUE);
+    // Override the expected filesize.
+    $expected['data']['attributes']['filesize'] = strlen($php_string);
+    // The file mime should be 'application/msword'.
+    $expected['data']['attributes']['filemime'] = 'application/msword';
+    $this->assertResponseData($expected, $response);
+    $this->assertTrue(file_exists('public://foobar/example_3.php_.doc'));
+    $this->assertFalse(file_exists('public://foobar/example_3.php.doc'));
+
+    // Now allow insecure uploads.
+    \Drupal::configFactory()
+      ->getEditable('system.file')
+      ->set('allow_insecure_uploads', TRUE)
+      ->save();
+    // Allow all file uploads. This is very insecure.
+    $this->field->setSetting('file_extensions', '')->save();
+    $this->rebuildAll();
+
+    $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php"']);
+    $expected = $this->getExpectedDocument(4, 'example_4.php', TRUE);
+    // Override the expected filesize.
+    $expected['data']['attributes']['filesize'] = strlen($php_string);
+    // The file mime should also now be PHP.
+    $expected['data']['attributes']['filemime'] = 'application/x-httpd-php';
+    $this->assertResponseData($expected, $response);
+    $this->assertTrue(file_exists('public://foobar/example_4.php'));
+  }
+
+  /**
+   * Tests using the file upload POST route no extension configured.
+   */
+  public function testFileUploadNoExtensionSetting() {
+    $this->setUpAuthorization('POST');
+
+    $uri = Url::fromUri('base:' . static::$postUri);
+
+    $this->field->setSetting('file_extensions', '')
+      ->save();
+    $this->rebuildAll();
+
+    $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
+    $expected = $this->getExpectedDocument(1, 'example.txt', TRUE);
+
+    $this->assertResponseData($expected, $response);
+    $this->assertTrue(file_exists('public://foobar/example.txt'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The current user is not allowed to view this relationship. The 'view test entity' permission is required.";
+
+      case 'POST':
+        return "The current user is not permitted to upload a file for this field. The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
+
+      case 'PATCH':
+        return "The current user is not permitted to upload a file for this field. The 'administer entity_test content' permission is required.";
+    }
+  }
+
+  /**
+   * Returns the expected JSON:API document for the expected file entity.
+   *
+   * @param int $fid
+   *   The file ID to load and create a JSON:API document for.
+   * @param string $expected_filename
+   *   The expected filename for the stored file.
+   * @param bool $expected_as_filename
+   *   Whether the expected filename should be the filename property too.
+   * @param bool $expected_status
+   *   The expected file status. Defaults to FALSE.
+   *
+   * @return array
+   *   A JSON:API response document.
+   */
+  protected function getExpectedDocument($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE, $expected_status = FALSE) {
+    $author = User::load($this->account->id());
+    $file = File::load($fid);
+    $self_url = Url::fromUri('base:/jsonapi/file/file/' . $file->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $file->uuid(),
+        'type' => 'file--file',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => (new \DateTime())->setTimestamp($file->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'changed' => (new \DateTime())->setTimestamp($file->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'filemime' => 'text/plain',
+          'filename' => $expected_as_filename ? $expected_filename : 'example.txt',
+          'filesize' => strlen($this->testFileData),
+          'langcode' => 'en',
+          'status' => $expected_status,
+          'uri' => [
+            'value' => 'public://foobar/' . $expected_filename,
+            'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename),
+          ],
+          'drupal_internal__fid' => (int) $file->id(),
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/uid'],
+              'self' => ['href' => $self_url . '/relationships/uid'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Performs a file upload request. Wraps the Guzzle HTTP client.
+   *
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param string $file_contents
+   *   The file contents to send as the request body.
+   * @param array $headers
+   *   Additional headers to send with the request. Defaults will be added for
+   *   Content-Type and Content-Disposition. In order to remove the defaults set
+   *   the header value to FALSE.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   *   The received response.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function fileRequest(Url $url, $file_contents, array $headers = []) {
+    $request_options = [];
+    $headers = $headers + [
+      // Set the required (and only accepted) content type for the request.
+      'Content-Type' => 'application/octet-stream',
+      // Set the required Content-Disposition header for the file name.
+      'Content-Disposition' => 'file; filename="example.txt"',
+      // Set the required JSON:API Accept header.
+      'Accept' => 'application/vnd.api+json',
+    ];
+    $request_options[RequestOptions::HEADERS] = array_filter($headers, function ($value) {
+      return $value !== FALSE;
+    });
+    $request_options[RequestOptions::BODY] = $file_contents;
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    return $this->request('POST', $url, $request_options);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view test entity']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']);
+        break;
+
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['administer entity_test content', 'access content']);
+        break;
+    }
+  }
+
+  /**
+   * Asserts expected normalized data matches response data.
+   *
+   * @param array $expected
+   *   The expected data.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The file upload response.
+   */
+  protected function assertResponseData(array $expected, ResponseInterface $response) {
+    static::recursiveKSort($expected);
+    $actual = Json::decode((string) $response->getBody());
+    static::recursiveKSort($actual);
+
+    $this->assertSame($expected, $actual);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // There is cacheability metadata to check as file uploads only allows POST
+    // requests, which will not return cacheable responses.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/FilterFormatTest.php b/core/modules/jsonapi/tests/src/Functional/FilterFormatTest.php
new file mode 100644
index 0000000000..8af68bb363
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FilterFormatTest.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\filter\Entity\FilterFormat;
+
+/**
+ * JSON:API integration test for the "FilterFormat" config entity type.
+ *
+ * @group jsonapi
+ */
+class FilterFormatTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['filter'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'filter_format';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'filter_format--filter_format';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\filter\FilterFormatInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer filters']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $pablo_format = FilterFormat::create([
+      'name' => 'Pablo Piccasso',
+      'format' => 'pablo',
+      'langcode' => 'es',
+      'filters' => [
+        'filter_html' => [
+          'status' => TRUE,
+          'settings' => [
+            'allowed_html' => '<p> <a> <b> <lo>',
+          ],
+        ],
+      ],
+    ]);
+    $pablo_format->save();
+    return $pablo_format;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/filter_format/filter_format/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'filter_format--filter_format',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'filters' => [
+            'filter_html' => [
+              'id' => 'filter_html',
+              'provider' => 'filter',
+              'status' => TRUE,
+              'weight' => -10,
+              'settings' => [
+                'allowed_html' => '<p> <a> <b> <lo>',
+                'filter_html_help' => TRUE,
+                'filter_html_nofollow' => FALSE,
+              ],
+            ],
+          ],
+          'langcode' => 'es',
+          'name' => 'Pablo Piccasso',
+          'status' => TRUE,
+          'weight' => 0,
+          'drupal_internal__format' => 'pablo',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ImageStyleTest.php b/core/modules/jsonapi/tests/src/Functional/ImageStyleTest.php
new file mode 100644
index 0000000000..55193d81a5
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ImageStyleTest.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\image\Entity\ImageStyle;
+
+/**
+ * JSON:API integration test for the "ImageStyle" config entity type.
+ *
+ * @group jsonapi
+ */
+class ImageStyleTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['image'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'image_style';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'image_style--image_style';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\image\ImageStyleInterface
+   */
+  protected $entity;
+
+  /**
+   * The effect UUID.
+   *
+   * @var string
+   */
+  protected $effectUuid;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer image styles']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" image style.
+    $camelids = ImageStyle::create([
+      'name' => 'camelids',
+      'label' => 'Camelids',
+    ]);
+
+    // Add an image effect.
+    $effect = [
+      'id' => 'image_scale_and_crop',
+      'data' => [
+        'width' => 120,
+        'height' => 121,
+      ],
+      'weight' => 0,
+    ];
+    $this->effectUuid = $camelids->addImageEffect($effect);
+
+    $camelids->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/image_style/image_style/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'image_style--image_style',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'effects' => [
+            $this->effectUuid => [
+              'uuid' => $this->effectUuid,
+              'id' => 'image_scale_and_crop',
+              'weight' => 0,
+              'data' => [
+                'anchor' => 'center-center',
+                'width' => 120,
+                'height' => 121,
+              ],
+            ],
+          ],
+          'label' => 'Camelids',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'drupal_internal__name' => 'camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/InternalEntitiesTest.php b/core/modules/jsonapi/tests/src/Functional/InternalEntitiesTest.php
new file mode 100644
index 0000000000..e170222a08
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/InternalEntitiesTest.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\entity_test\Entity\EntityTestBundle;
+use Drupal\entity_test\Entity\EntityTestNoLabel;
+use Drupal\entity_test\Entity\EntityTestWithBundle;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
+
+/**
+ * Makes assertions about the JSON:API behavior for internal entities.
+ *
+ * @group jsonapi
+ *
+ * @internal
+ */
+class InternalEntitiesTest extends BrowserTestBase {
+
+  use EntityReferenceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'jsonapi',
+    'entity_test',
+    'serialization',
+  ];
+
+  /**
+   * A test user.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $testUser;
+
+  /**
+   * An entity of an internal entity type.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $internalEntity;
+
+  /**
+   * An entity referencing an internal entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $referencingEntity;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->testUser = $this->drupalCreateUser([
+      'view test entity',
+      'administer entity_test_with_bundle content',
+    ], $this->randomString(), TRUE);
+    EntityTestBundle::create([
+      'id' => 'internal_referencer',
+      'label' => 'Entity Test Internal Referencer',
+    ])->save();
+    $this->createEntityReferenceField(
+      'entity_test_with_bundle',
+      'internal_referencer',
+      'field_internal',
+      'Internal Entities',
+      'entity_test_no_label'
+    );
+    $this->internalEntity = EntityTestNoLabel::create([]);
+    $this->internalEntity->save();
+    $this->referencingEntity = EntityTestWithBundle::create([
+      'type' => 'internal_referencer',
+      'field_internal' => $this->internalEntity->id(),
+    ]);
+    $this->referencingEntity->save();
+    drupal_flush_all_caches();
+  }
+
+  /**
+   * Ensures that internal resources types aren't present in the entry point.
+   */
+  public function testEntryPoint() {
+    $document = $this->jsonapiGet('/jsonapi');
+    $this->assertArrayNotHasKey(
+      "{$this->internalEntity->getEntityTypeId()}--{$this->internalEntity->bundle()}",
+      $document['links'],
+      'The entry point should not contain links to internal resource type routes.'
+    );
+  }
+
+  /**
+   * Ensures that internal resources types aren't present in the routes.
+   */
+  public function testRoutes() {
+    // This cannot be in a data provider because it needs values created by the
+    // setUp method.
+    $paths = [
+      'individual' => "/jsonapi/entity_test_no_label/entity_test_no_label/{$this->internalEntity->uuid()}",
+      'collection' => "/jsonapi/entity_test_no_label/entity_test_no_label",
+      'related' => "/jsonapi/entity_test_no_label/entity_test_no_label/{$this->internalEntity->uuid()}/field_internal",
+    ];
+    $this->drupalLogin($this->testUser);
+    foreach ($paths as $type => $path) {
+      $this->drupalGet($path, ['Accept' => 'application/vnd.api+json']);
+      $this->assertSame(404, $this->getSession()->getStatusCode());
+    }
+  }
+
+  /**
+   * Asserts that internal entities are not included in compound documents.
+   */
+  public function testIncludes() {
+    $document = $this->getIndividual($this->referencingEntity, [
+      'query' => ['include' => 'field_internal'],
+    ]);
+    $this->assertArrayNotHasKey(
+      'included',
+      $document,
+      'Internal entities should not be included in compound documents.'
+    );
+  }
+
+  /**
+   * Asserts that links to internal relationships aren't generated.
+   */
+  public function testLinks() {
+    $document = $this->getIndividual($this->referencingEntity);
+    $this->assertArrayNotHasKey(
+      'related',
+      $document['data']['relationships']['field_internal']['links'],
+      'Links to internal-only related routes should not be in the document.'
+    );
+  }
+
+  /**
+   * Returns the decoded JSON:API document for the for the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to request.
+   * @param array $options
+   *   URL options.
+   *
+   * @return array
+   *   The decoded response document.
+   */
+  protected function getIndividual(EntityInterface $entity, array $options = []) {
+    $entity_type_id = $entity->getEntityTypeId();
+    $bundle = $entity->bundle();
+    $path = "/jsonapi/{$entity_type_id}/{$bundle}/{$entity->uuid()}";
+    return $this->jsonapiGet($path, $options);
+  }
+
+  /**
+   * Performs an authenticated request and returns the decoded document.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to request.
+   * @param string $relationship
+   *   The field name of the relationship to request.
+   * @param array $options
+   *   URL options.
+   *
+   * @return array
+   *   The decoded response document.
+   */
+  protected function getRelated(EntityInterface $entity, $relationship, array $options = []) {
+    $entity_type_id = $entity->getEntityTypeId();
+    $bundle = $entity->bundle();
+    $path = "/jsonapi/{$entity_type_id}/{$bundle}/{$entity->uuid()}/{$relationship}";
+    return $this->jsonapiGet($path, $options);
+  }
+
+  /**
+   * Performs an authenticated request and returns the decoded document.
+   */
+  protected function jsonapiGet($path, array $options = []) {
+    $this->drupalLogin($this->testUser);
+    $response = $this->drupalGet($path, $options, ['Accept' => 'application/vnd.api+json']);
+    return Json::decode($response);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ItemTest.php b/core/modules/jsonapi/tests/src/Functional/ItemTest.php
new file mode 100644
index 0000000000..6e3a48f027
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ItemTest.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\aggregator\Entity\Feed;
+use Drupal\aggregator\Entity\Item;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+
+/**
+ * JSON:API integration test for the "Item" content entity type.
+ *
+ * @group jsonapi
+ */
+class ItemTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['aggregator'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'aggregator_item';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'aggregator_item--aggregator_item';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\aggregator\ItemInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access news feeds']);
+        break;
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer news feeds']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" feed.
+    $feed = Feed::create([
+      'title' => 'Camelids',
+      'url' => 'https://groups.drupal.org/not_used/167169',
+      'refresh' => 900,
+      'checked' => 1389919932,
+      'description' => 'Drupal Core Group feed',
+    ]);
+    $feed->save();
+
+    // Create a "Llama" item.
+    $item = Item::create();
+    $item->setTitle('Llama')
+      ->setFeedId($feed->id())
+      ->setLink('https://www.drupal.org/')
+      ->setPostedTime(123456789)
+      ->save();
+
+    return $item;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    $duplicate = $this->getEntityDuplicate($this->entity, $key);
+    $duplicate->setLink('https://www.example.org/');
+    $duplicate->save();
+    return $duplicate;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access news feeds' permission is required.";
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        return "The 'administer news feeds' permission is required.";
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetIndividual() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollection() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelationships() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDeleteIndividual() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalMultilingualTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalMultilingualTest.php
new file mode 100644
index 0000000000..a060d9aa17
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalMultilingualTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\language\Entity\ConfigurableLanguage;
+
+/**
+ * Tests JSON:API multilingual support.
+ *
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class JsonApiFunctionalMultilingualTest extends JsonApiFunctionalTestBase {
+
+  public static $modules = [
+    'basic_auth',
+    'jsonapi',
+    'serialization',
+    'node',
+    'image',
+    'taxonomy',
+    'link',
+    'language',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $language = ConfigurableLanguage::createFromLangcode('ca');
+    $language->save();
+
+    // In order to reflect the changes for a multilingual site in the container
+    // we have to rebuild it.
+    $this->rebuildContainer();
+
+    \Drupal::configFactory()->getEditable('language.negotiation')
+      ->set('url.prefixes.ca', 'ca')
+      ->save();
+  }
+
+  /**
+   * Tests reading multilingual content.
+   */
+  public function testReadMultilingual() {
+    $this->createDefaultContent(5, 5, TRUE, TRUE, static::IS_MULTILINGUAL, FALSE);
+
+    // Different databases have different sort orders, so a sort is required so
+    // test expectations do not need to vary per database.
+    $default_sort = ['sort' => 'drupal_internal__nid'];
+
+    // Test reading an individual entity.
+    $output = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $this->nodes[0]->uuid(), ['query' => ['include' => 'field_tags,field_image'] + $default_sort]));
+    $this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data']['attributes']['title']);
+
+    $included_tags = array_filter($output['included'], function ($entry) {
+      return $entry['type'] === 'taxonomy_term--tags';
+    });
+    $tag_name = $this->nodes[0]->get('field_tags')->entity
+      ->getTranslation('ca')->getName();
+    // TODO figure out how to fetcht the alt text of an image.
+    $this->assertEquals($tag_name, reset($included_tags)['attributes']['name']);
+
+    $output = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $this->nodes[0]->uuid(), ['query' => $default_sort]));
+    $this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data']['attributes']['title']);
+
+    // Test reading a collection of entities.
+    $output = Json::decode($this->drupalGet('/ca/jsonapi/node/article', ['query' => $default_sort]));
+    $this->assertEquals($this->nodes[0]->getTranslation('ca')->getTitle(), $output['data'][0]['attributes']['title']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
new file mode 100644
index 0000000000..80db077449
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
@@ -0,0 +1,876 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\node\Entity\Node;
+
+/**
+ * General functional test class.
+ *
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class JsonApiFunctionalTest extends JsonApiFunctionalTestBase {
+
+  /**
+   * Test the GET method.
+   */
+  public function testRead() {
+    $this->createDefaultContent(61, 5, TRUE, TRUE, static::IS_NOT_MULTILINGUAL, FALSE);
+    // Unpublish the last entity, so we can check access.
+    $this->nodes[60]->setUnpublished()->save();
+
+    // Different databases have different sort orders, so a sort is required so
+    // test expectations do not need to vary per database.
+    $default_sort = ['sort' => 'drupal_internal__nid'];
+
+    // 0. HEAD request allows a client to verify that JSON:API is installed.
+    $this->httpClient->request('HEAD', $this->buildUrl('/jsonapi/node/article'));
+    $this->assertSession()->statusCodeEquals(200);
+    // 1. Load all articles (1st page).
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    $this->assertSession()
+      ->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
+    // 2. Load all articles (Offset 3).
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['page' => ['offset' => 3]] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    $this->assertContains('page%5Boffset%5D=53', $collection_output['links']['next']['href']);
+    // 3. Load all articles (1st page, 2 items)
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['page' => ['limit' => 2]] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(2, count($collection_output['data']));
+    // 4. Load all articles (2nd page, 2 items).
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => [
+        'page' => [
+          'limit' => 2,
+          'offset' => 2,
+        ],
+      ] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(2, count($collection_output['data']));
+    $this->assertContains('page%5Boffset%5D=4', $collection_output['links']['next']['href']);
+    // 5. Single article.
+    $uuid = $this->nodes[0]->uuid();
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertArrayHasKey('type', $single_output['data']);
+    $this->assertEquals($this->nodes[0]->getTitle(), $single_output['data']['attributes']['title']);
+
+    // 5.1 Single article with access denied because unauthenticated.
+    Json::decode($this->drupalGet('/jsonapi/node/article/' . $this->nodes[60]->uuid()));
+    $this->assertSession()->statusCodeEquals(401);
+
+    // 5.1 Single article with access denied while authenticated.
+    $this->drupalLogin($this->userCanViewProfiles);
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $this->nodes[60]->uuid()));
+    $this->assertSession()->statusCodeEquals(403);
+    $this->assertEquals('/data', $single_output['errors'][0]['source']['pointer']);
+    $this->drupalLogout();
+
+    // 6. Single relationship item.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/node_type'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertArrayHasKey('type', $single_output['data']);
+    $this->assertArrayNotHasKey('attributes', $single_output['data']);
+    $this->assertArrayHasKey('related', $single_output['links']);
+    // 7. Single relationship image.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_image'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertArrayHasKey('type', $single_output['data']);
+    $this->assertArrayNotHasKey('attributes', $single_output['data']);
+    $this->assertArrayHasKey('related', $single_output['links']);
+    // 8. Multiple relationship item.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_tags'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertArrayHasKey('type', $single_output['data'][0]);
+    $this->assertArrayNotHasKey('attributes', $single_output['data'][0]);
+    $this->assertArrayHasKey('related', $single_output['links']);
+    // 8b. Single related item, empty.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/field_heroless'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSame(NULL, $single_output['data']);
+    // 9. Related tags with includes.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/field_tags', [
+      'query' => ['include' => 'vid'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals('taxonomy_term--tags', $single_output['data'][0]['type']);
+    $this->assertArrayNotHasKey('tid', $single_output['data'][0]['attributes']);
+    $this->assertContains(
+      '/taxonomy_term/tags/',
+      $single_output['data'][0]['links']['self']['href']
+    );
+    $this->assertEquals(
+      'taxonomy_vocabulary--taxonomy_vocabulary',
+      $single_output['included'][0]['type']
+    );
+    // 10. Single article with includes.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid, [
+      'query' => ['include' => 'uid,field_tags'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals('node--article', $single_output['data']['type']);
+    $first_include = reset($single_output['included']);
+    $this->assertEquals(
+      'user--user',
+      $first_include['type']
+    );
+    $last_include = end($single_output['included']);
+    $this->assertEquals(
+      'taxonomy_term--tags',
+      $last_include['type']
+    );
+
+    // 10b. Single article with nested includes.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid, [
+      'query' => ['include' => 'field_tags,field_tags.vid'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals('node--article', $single_output['data']['type']);
+    $first_include = reset($single_output['included']);
+    $this->assertEquals(
+      'taxonomy_term--tags',
+      $first_include['type']
+    );
+    $last_include = end($single_output['included']);
+    $this->assertEquals(
+      'taxonomy_vocabulary--taxonomy_vocabulary',
+      $last_include['type']
+    );
+
+    // 11. Includes with relationships.
+    $this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/uid');
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/uid', [
+      'query' => ['include' => 'uid'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals('user--user', $single_output['data']['type']);
+    $this->assertArrayHasKey('related', $single_output['links']);
+    $this->assertArrayHasKey('included', $single_output);
+    $first_include = reset($single_output['included']);
+    $this->assertEquals(
+      'user--user',
+      $first_include['type']
+    );
+    $this->assertFalse(empty($first_include['attributes']));
+    $this->assertTrue(empty($first_include['attributes']['mail']));
+    $this->assertTrue(empty($first_include['attributes']['pass']));
+    // 12. Collection with one access denied.
+    $this->nodes[1]->set('status', FALSE);
+    $this->nodes[1]->save();
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['page' => ['limit' => 2]] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(1, count($single_output['data']));
+    $this->assertEquals(1, count(array_filter(array_keys($single_output['meta']['omitted']['links']), function ($key) {
+      return $key !== 'help';
+    })));
+    $link_keys = array_keys($single_output['meta']['omitted']['links']);
+    $this->assertSame('help', reset($link_keys));
+    $this->assertRegExp('/^item:[a-zA-Z0-9]{7}$/', next($link_keys));
+    $this->nodes[1]->set('status', TRUE);
+    $this->nodes[1]->save();
+    // 13. Test filtering when using short syntax.
+    $filter = [
+      'uid.id' => ['value' => $this->user->uuid()],
+      'field_tags.id' => ['value' => $this->tags[0]->uuid()],
+    ];
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter, 'include' => 'uid,field_tags'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThan(0, count($single_output['data']));
+    // 14. Test filtering when using long syntax.
+    $filter = [
+      'and_group' => ['group' => ['conjunction' => 'AND']],
+      'filter_user' => [
+        'condition' => [
+          'path' => 'uid.id',
+          'value' => $this->user->uuid(),
+          'memberOf' => 'and_group',
+        ],
+      ],
+      'filter_tags' => [
+        'condition' => [
+          'path' => 'field_tags.id',
+          'value' => $this->tags[0]->uuid(),
+          'memberOf' => 'and_group',
+        ],
+      ],
+    ];
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter, 'include' => 'uid,field_tags'],
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThan(0, count($single_output['data']));
+    // 15. Test filtering when using invalid syntax.
+    $filter = [
+      'and_group' => ['group' => ['conjunction' => 'AND']],
+      'filter_user' => [
+        'condition' => [
+          'name-with-a-typo' => 'uid.id',
+          'value' => $this->user->uuid(),
+          'memberOf' => 'and_group',
+        ],
+      ],
+    ];
+    $this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]);
+    $this->assertSession()->statusCodeEquals(400);
+    // 16. Test filtering on the same field.
+    $filter = [
+      'or_group' => ['group' => ['conjunction' => 'OR']],
+      'filter_tags_1' => [
+        'condition' => [
+          'path' => 'field_tags.id',
+          'value' => $this->tags[0]->uuid(),
+          'memberOf' => 'or_group',
+        ],
+      ],
+      'filter_tags_2' => [
+        'condition' => [
+          'path' => 'field_tags.id',
+          'value' => $this->tags[1]->uuid(),
+          'memberOf' => 'or_group',
+        ],
+      ],
+    ];
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter, 'include' => 'field_tags'] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(2, count($single_output['included']));
+    // 17. Single user (check fields lacking 'view' access).
+    $user_url = Url::fromRoute('jsonapi.user--user.individual', [
+      'entity' => $this->user->uuid(),
+    ]);
+    $response = $this->request('GET', $user_url, [
+      'auth' => [
+        $this->userCanViewProfiles->getUsername(),
+        $this->userCanViewProfiles->pass_raw,
+      ],
+    ]);
+    $single_output = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(200, $response->getStatusCode());
+    $this->assertEquals('user--user', $single_output['data']['type']);
+    $this->assertEquals($this->user->get('name')->value, $single_output['data']['attributes']['name']);
+    $this->assertTrue(empty($single_output['data']['attributes']['mail']));
+    $this->assertTrue(empty($single_output['data']['attributes']['pass']));
+    // 18. Test filtering on the column of a link.
+    $filter = [
+      'linkUri' => [
+        'condition' => [
+          'path' => 'field_link.uri',
+          'value' => 'https://',
+          'operator' => 'STARTS_WITH',
+        ],
+      ],
+    ];
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(1, count($single_output['data']));
+    // 19. Test non-existing route without 'Accept' header.
+    $this->drupalGet('/jsonapi/node/article/broccoli');
+    $this->assertSession()->statusCodeEquals(404);
+    // Even without the 'Accept' header the 404 error is formatted as JSON:API.
+    $this->assertSession()->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
+    // 20. Test non-existing route with 'Accept' header.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/broccoli', [], [
+      'Accept' => 'application/vnd.api+json',
+    ]));
+    $this->assertEquals(404, $single_output['errors'][0]['status']);
+    $this->assertSession()->statusCodeEquals(404);
+    // With the 'Accept' header we can know we want the 404 error formatted as
+    // JSON:API.
+    $this->assertSession()->responseHeaderContains('Content-Type', 'application/vnd.api+json');
+    // 22. Test sort criteria on multiple fields: both ASC.
+    $output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => [
+        'page[limit]' => 6,
+        'sort' => 'field_sort1,field_sort2',
+      ],
+    ]));
+    $output_uuids = array_map(function ($result) {
+      return $result['id'];
+    }, $output['data']);
+    $this->assertCount(6, $output_uuids);
+    $this->assertSame([
+      Node::load(5)->uuid(),
+      Node::load(4)->uuid(),
+      Node::load(3)->uuid(),
+      Node::load(2)->uuid(),
+      Node::load(1)->uuid(),
+      Node::load(10)->uuid(),
+    ], $output_uuids);
+    // 23. Test sort criteria on multiple fields: first ASC, second DESC.
+    $output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => [
+        'page[limit]' => 6,
+        'sort' => 'field_sort1,-field_sort2',
+      ],
+    ]));
+    $output_uuids = array_map(function ($result) {
+      return $result['id'];
+    }, $output['data']);
+    $this->assertCount(6, $output_uuids);
+    $this->assertSame([
+      Node::load(1)->uuid(),
+      Node::load(2)->uuid(),
+      Node::load(3)->uuid(),
+      Node::load(4)->uuid(),
+      Node::load(5)->uuid(),
+      Node::load(6)->uuid(),
+    ], $output_uuids);
+    // 24. Test sort criteria on multiple fields: first DESC, second ASC.
+    $output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => [
+        'page[limit]' => 6,
+        'sort' => '-field_sort1,field_sort2',
+      ],
+    ]));
+    $output_uuids = array_map(function ($result) {
+      return $result['id'];
+    }, $output['data']);
+    $this->assertCount(5, $output_uuids);
+    $this->assertCount(2, $output['meta']['omitted']['links']);
+    $this->assertSame([
+      Node::load(60)->uuid(),
+      Node::load(59)->uuid(),
+      Node::load(58)->uuid(),
+      Node::load(57)->uuid(),
+      Node::load(56)->uuid(),
+    ], $output_uuids);
+    // 25. Test sort criteria on multiple fields: both DESC.
+    $output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => [
+        'page[limit]' => 6,
+        'sort' => '-field_sort1,-field_sort2',
+      ],
+    ]));
+    $output_uuids = array_map(function ($result) {
+      return $result['id'];
+    }, $output['data']);
+    $this->assertCount(5, $output_uuids);
+    $this->assertCount(2, $output['meta']['omitted']['links']);
+    $this->assertSame([
+      Node::load(56)->uuid(),
+      Node::load(57)->uuid(),
+      Node::load(58)->uuid(),
+      Node::load(59)->uuid(),
+      Node::load(60)->uuid(),
+    ], $output_uuids);
+    // 25. Test collection count.
+    $this->container->get('module_installer')->install(['jsonapi_test_collection_count']);
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(61, $collection_output['meta']['count']);
+    $this->container->get('module_installer')->uninstall(['jsonapi_test_collection_count']);
+
+    // Test documentation filtering examples.
+    // 1. Only get published nodes.
+    $filter = [
+      'status-filter' => [
+        'condition' => [
+          'path' => 'status',
+          'value' => 1,
+        ],
+      ],
+    ];
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    // 2. Nested Filters: Get nodes created by user admin.
+    $filter = [
+      'name-filter' => [
+        'condition' => [
+          'path' => 'uid.name',
+          'value' => $this->user->getAccountName(),
+        ],
+      ],
+    ];
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    // 3. Filtering with arrays: Get nodes created by users [admin, john].
+    $filter = [
+      'name-filter' => [
+        'condition' => [
+          'path' => 'uid.name',
+          'operator' => 'IN',
+          'value' => [
+            $this->user->getAccountName(),
+            $this->getRandomGenerator()->name(),
+          ],
+        ],
+      ],
+    ];
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    // 4. Grouping filters: Get nodes that are published and create by admin.
+    $filter = [
+      'and-group' => [
+        'group' => [
+          'conjunction' => 'AND',
+        ],
+      ],
+      'name-filter' => [
+        'condition' => [
+          'path' => 'uid.name',
+          'value' => $this->user->getAccountName(),
+          'memberOf' => 'and-group',
+        ],
+      ],
+      'status-filter' => [
+        'condition' => [
+          'path' => 'status',
+          'value' => 1,
+          'memberOf' => 'and-group',
+        ],
+      ],
+    ];
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertGreaterThanOrEqual(OffsetPage::SIZE_MAX, count($collection_output['data']));
+    // 5. Grouping grouped filters: Get nodes that are promoted or sticky and
+    //    created by admin.
+    $filter = [
+      'and-group' => [
+        'group' => [
+          'conjunction' => 'AND',
+        ],
+      ],
+      'or-group' => [
+        'group' => [
+          'conjunction' => 'OR',
+          'memberOf' => 'and-group',
+        ],
+      ],
+      'admin-filter' => [
+        'condition' => [
+          'path' => 'uid.name',
+          'value' => $this->user->getAccountName(),
+          'memberOf' => 'and-group',
+        ],
+      ],
+      'sticky-filter' => [
+        'condition' => [
+          'path' => 'sticky',
+          'value' => 1,
+          'memberOf' => 'or-group',
+        ],
+      ],
+      'promote-filter' => [
+        'condition' => [
+          'path' => 'promote',
+          'value' => 0,
+          'memberOf' => 'or-group',
+        ],
+      ],
+    ];
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
+      'query' => ['filter' => $filter] + $default_sort,
+    ]));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(0, count($collection_output['data']));
+  }
+
+  /**
+   * Test the GET method on articles referencing the same tag twice.
+   */
+  public function testReferencingTwiceRead() {
+    $this->createDefaultContent(1, 1, FALSE, FALSE, static::IS_NOT_MULTILINGUAL, TRUE);
+
+    // 1. Load all articles (1st page).
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(1, count($collection_output['data']));
+    $this->assertSession()
+      ->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
+  }
+
+  /**
+   * Test POST, PATCH and DELETE.
+   */
+  public function testWrite() {
+    $this->createDefaultContent(0, 3, FALSE, FALSE, static::IS_NOT_MULTILINGUAL, FALSE);
+    // 1. Successful post.
+    $collection_url = Url::fromRoute('jsonapi.node--article.collection.post');
+    $body = [
+      'data' => [
+        'type' => 'node--article',
+        'attributes' => [
+          'langcode' => 'en',
+          'title' => 'My custom title',
+          'default_langcode' => '1',
+          'body' => [
+            'value' => 'Custom value',
+            'format' => 'plain_text',
+            'summary' => 'Custom summary',
+          ],
+        ],
+        'relationships' => [
+          'field_tags' => [
+            'data' => [
+              [
+                'type' => 'taxonomy_term--tags',
+                'id' => $this->tags[0]->uuid(),
+              ],
+              [
+                'type' => 'taxonomy_term--tags',
+                'id' => $this->tags[1]->uuid(),
+              ],
+            ],
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(201, $response->getStatusCode());
+    $this->assertArrayNotHasKey('uuid', $created_response['data']['attributes']);
+    $uuid = $created_response['data']['id'];
+    $this->assertEquals(2, count($created_response['data']['relationships']['field_tags']['data']));
+    $this->assertEquals($created_response['data']['links']['self']['href'], $response->getHeader('Location')[0]);
+
+    // 2. Authorization error.
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body),
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(401, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Unauthorized', $created_response['errors'][0]['title']);
+
+    // 2.1 Authorization error with a user without create permissions.
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->userCanViewProfiles->getUsername(), $this->userCanViewProfiles->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(403, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Forbidden', $created_response['errors'][0]['title']);
+
+    // 3. Missing Content-Type error.
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Accept' => 'application/vnd.api+json'],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(415, $response->getStatusCode());
+
+    // 4. Article with a duplicate ID.
+    $invalid_body = $body;
+    $invalid_body['data']['id'] = Node::load(1)->uuid();
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($invalid_body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Accept' => 'application/vnd.api+json',
+        'Content-Type' => 'application/vnd.api+json',
+      ],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(409, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Conflict', $created_response['errors'][0]['title']);
+    // 5. Article with wrong reference UUIDs for tags.
+    $body_invalid_tags = $body;
+    $body_invalid_tags['data']['relationships']['field_tags']['data'][0]['id'] = 'lorem';
+    $body_invalid_tags['data']['relationships']['field_tags']['data'][1]['id'] = 'ipsum';
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body_invalid_tags),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(404, $response->getStatusCode());
+    // 6. Decoding error.
+    $response = $this->request('POST', $collection_url, [
+      'body' => '{"bad json",,,}',
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(400, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Bad Request', $created_response['errors'][0]['title']);
+    // 6.1 Denormalizing error.
+    $response = $this->request('POST', $collection_url, [
+      'body' => '{"data":{"type":"something"},"valid yet nonsensical json":[]}',
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ]);
+    $created_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(422, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Unprocessable Entity', $created_response['errors'][0]['title']);
+    // 6.2 Relationships are not included in "data".
+    $malformed_body = $body;
+    unset($malformed_body['data']['relationships']);
+    $malformed_body['relationships'] = $body['data']['relationships'];
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($malformed_body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Accept' => 'application/vnd.api+json',
+        'Content-Type' => 'application/vnd.api+json',
+      ],
+    ]);
+    $created_response = Json::decode((string) $response->getBody());
+    $this->assertSame(400, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertSame("Bad Request", $created_response['errors'][0]['title']);
+    $this->assertSame("Found \"relationships\" within the document's top level. The \"relationships\" key must be within resource object.", $created_response['errors'][0]['detail']);
+    // 6.2 "type" not included in "data".
+    $missing_type = $body;
+    unset($missing_type['data']['type']);
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($missing_type),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Accept' => 'application/vnd.api+json',
+        'Content-Type' => 'application/vnd.api+json',
+      ],
+    ]);
+    $created_response = Json::decode((string) $response->getBody());
+    $this->assertSame(400, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertSame("Bad Request", $created_response['errors'][0]['title']);
+    $this->assertSame("Resource object must include a \"type\".", $created_response['errors'][0]['detail']);
+    // 7. Successful PATCH.
+    $body = [
+      'data' => [
+        'id' => $uuid,
+        'type' => 'node--article',
+        'attributes' => ['title' => 'My updated title'],
+      ],
+    ];
+    $individual_url = Url::fromRoute('jsonapi.node--article.individual', [
+      'entity' => $uuid,
+    ]);
+    $response = $this->request('PATCH', $individual_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(200, $response->getStatusCode());
+    $this->assertEquals('My updated title', $updated_response['data']['attributes']['title']);
+
+    // 7.1 Unsuccessful PATCH due to access restrictions.
+    $body = [
+      'data' => [
+        'id' => $uuid,
+        'type' => 'node--article',
+        'attributes' => ['title' => 'My updated title'],
+      ],
+    ];
+    $individual_url = Url::fromRoute('jsonapi.node--article.individual', [
+      'entity' => $uuid,
+    ]);
+    $response = $this->request('PATCH', $individual_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->userCanViewProfiles->getUsername(), $this->userCanViewProfiles->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $this->assertEquals(403, $response->getStatusCode());
+
+    // 8. Field access forbidden check.
+    $body = [
+      'data' => [
+        'id' => $uuid,
+        'type' => 'node--article',
+        'attributes' => [
+          'title' => 'My updated title',
+          'status' => 0,
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $individual_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(403, $response->getStatusCode());
+    $this->assertEquals("The current user is not allowed to PATCH the selected field (status). The 'administer nodes' permission is required.",
+      $updated_response['errors'][0]['detail']);
+
+    $node = \Drupal::entityManager()->loadEntityByUuid('node', $uuid);
+    $this->assertEquals(1, $node->get('status')->value, 'Node status was not changed.');
+    // 9. Successful POST to related endpoint.
+    $body = [
+      'data' => [
+        [
+          'id' => $this->tags[2]->uuid(),
+          'type' => 'taxonomy_term--tags',
+        ],
+      ],
+    ];
+    $relationship_url = Url::fromRoute('jsonapi.node--article.field_tags.relationship.post', [
+      'entity' => $uuid,
+    ]);
+    $response = $this->request('POST', $relationship_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(200, $response->getStatusCode());
+    $this->assertEquals(3, count($updated_response['data']));
+    $this->assertEquals('taxonomy_term--tags', $updated_response['data'][2]['type']);
+    $this->assertEquals($this->tags[2]->uuid(), $updated_response['data'][2]['id']);
+    // 10. Successful PATCH to related endpoint.
+    $body = [
+      'data' => [
+        [
+          'id' => $this->tags[1]->uuid(),
+          'type' => 'taxonomy_term--tags',
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $relationship_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $this->assertEquals(204, $response->getStatusCode());
+    $this->assertEmpty($response->getBody()->__toString());
+    // 11. Successful DELETE to related endpoint.
+    $response = $this->request('DELETE', $relationship_url, [
+      // Send a request with no body.
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(
+      'You need to provide a body for DELETE operations on a relationship (field_tags).',
+      $updated_response['errors'][0]['detail']
+    );
+    $this->assertEquals(400, $response->getStatusCode());
+    $response = $this->request('DELETE', $relationship_url, [
+      // Send a request with no authentication.
+      'body' => Json::encode($body),
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $this->assertEquals(401, $response->getStatusCode());
+    $response = $this->request('DELETE', $relationship_url, [
+      // Remove the existing relationship item.
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $this->assertEquals(204, $response->getStatusCode());
+    $this->assertEmpty($response->getBody()->__toString());
+    // 12. PATCH with invalid title and body format.
+    $body = [
+      'data' => [
+        'id' => $uuid,
+        'type' => 'node--article',
+        'attributes' => [
+          'title' => '',
+          'body' => [
+            'value' => 'Custom value',
+            'format' => 'invalid_format',
+            'summary' => 'Custom summary',
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $individual_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(422, $response->getStatusCode());
+    $this->assertCount(2, $updated_response['errors']);
+    for ($i = 0; $i < 2; $i++) {
+      $this->assertEquals("Unprocessable Entity", $updated_response['errors'][$i]['title']);
+      $this->assertEquals(422, $updated_response['errors'][$i]['status']);
+    }
+    $this->assertEquals("title: This value should not be null.", $updated_response['errors'][0]['detail']);
+    $this->assertEquals("body.0.format: The value you selected is not a valid choice.", $updated_response['errors'][1]['detail']);
+    $this->assertEquals("/data/attributes/title", $updated_response['errors'][0]['source']['pointer']);
+    $this->assertEquals("/data/attributes/body/format", $updated_response['errors'][1]['source']['pointer']);
+    // 13. PATCH with field that doesn't exist on Entity.
+    $body = [
+      'data' => [
+        'id' => $uuid,
+        'type' => 'node--article',
+        'attributes' => [
+          'field_that_doesnt_exist' => 'foobar',
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $individual_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(422, $response->getStatusCode());
+    $this->assertEquals("The attribute field_that_doesnt_exist does not exist on the node--article resource type.",
+      $updated_response['errors']['0']['detail']);
+    // 14. Successful DELETE.
+    $response = $this->request('DELETE', $individual_url, [
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+    ]);
+    $this->assertEquals(204, $response->getStatusCode());
+    $response = $this->request('GET', $individual_url, []);
+    $this->assertEquals(404, $response->getStatusCode());
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTestBase.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTestBase.php
new file mode 100644
index 0000000000..87ab13e724
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTestBase.php
@@ -0,0 +1,329 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\file\Entity\File;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\Exception\ClientException;
+use GuzzleHttp\Exception\ServerException;
+
+/**
+ * Provides helper methods for the JSON:API module's functional tests.
+ *
+ * @internal
+ */
+abstract class JsonApiFunctionalTestBase extends BrowserTestBase {
+
+  use EntityReferenceTestTrait;
+  use ImageFieldCreationTrait;
+
+  const IS_MULTILINGUAL = TRUE;
+  const IS_NOT_MULTILINGUAL = FALSE;
+
+  public static $modules = [
+    'basic_auth',
+    'jsonapi',
+    'serialization',
+    'node',
+    'image',
+    'taxonomy',
+    'link',
+  ];
+
+  /**
+   * Test user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * Test user with access to view profiles.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $userCanViewProfiles;
+
+  /**
+   * Test nodes.
+   *
+   * @var \Drupal\node\Entity\Node[]
+   */
+  protected $nodes = [];
+
+  /**
+   * Test taxonomy terms.
+   *
+   * @var \Drupal\taxonomy\Entity\Term[]
+   */
+  protected $tags = [];
+
+  /**
+   * Test files.
+   *
+   * @var \Drupal\file\Entity\File[]
+   */
+  protected $files = [];
+
+  /**
+   * The HTTP client.
+   *
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $httpClient;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Set up a HTTP client that accepts relative URLs.
+    $this->httpClient = $this->container->get('http_client_factory')
+      ->fromOptions(['base_uri' => $this->baseUrl]);
+
+    // Create Basic page and Article node types.
+    if ($this->profile != 'standard') {
+      $this->drupalCreateContentType([
+        'type' => 'article',
+        'name' => 'Article',
+      ]);
+
+      // Setup vocabulary.
+      Vocabulary::create([
+        'vid' => 'tags',
+        'name' => 'Tags',
+      ])->save();
+
+      // Add tags and field_image to the article.
+      $this->createEntityReferenceField(
+        'node',
+        'article',
+        'field_tags',
+        'Tags',
+        'taxonomy_term',
+        'default',
+        [
+          'target_bundles' => [
+            'tags' => 'tags',
+          ],
+          'auto_create' => TRUE,
+        ],
+        FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+      );
+      $this->createImageField('field_image', 'article');
+      $this->createImageField('field_heroless', 'article');
+    }
+
+    FieldStorageConfig::create([
+      'field_name' => 'field_link',
+      'entity_type' => 'node',
+      'type' => 'link',
+      'settings' => [],
+      'cardinality' => 1,
+    ])->save();
+
+    $field_config = FieldConfig::create([
+      'field_name' => 'field_link',
+      'label' => 'Link',
+      'entity_type' => 'node',
+      'bundle' => 'article',
+      'required' => FALSE,
+      'settings' => [],
+      'description' => '',
+    ]);
+    $field_config->save();
+
+    // Field for testing sorting.
+    FieldStorageConfig::create([
+      'field_name' => 'field_sort1',
+      'entity_type' => 'node',
+      'type' => 'integer',
+    ])->save();
+    FieldConfig::create([
+      'field_name' => 'field_sort1',
+      'entity_type' => 'node',
+      'bundle' => 'article',
+    ])->save();
+
+    // Another field for testing sorting.
+    FieldStorageConfig::create([
+      'field_name' => 'field_sort2',
+      'entity_type' => 'node',
+      'type' => 'integer',
+    ])->save();
+    FieldConfig::create([
+      'field_name' => 'field_sort2',
+      'entity_type' => 'node',
+      'bundle' => 'article',
+    ])->save();
+
+    $this->user = $this->drupalCreateUser([
+      'create article content',
+      'edit any article content',
+      'delete any article content',
+    ]);
+
+    // Create a user that can.
+    $this->userCanViewProfiles = $this->drupalCreateUser([
+      'access user profiles',
+    ]);
+
+    $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [
+      'access user profiles',
+      'administer taxonomy',
+    ]);
+
+    drupal_flush_all_caches();
+  }
+
+  /**
+   * Performs a HTTP request. Wraps the Guzzle HTTP client.
+   *
+   * Why wrap the Guzzle HTTP client? Because any error response is returned via
+   * an exception, which would make the tests unnecessarily complex to read.
+   *
+   * @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
+   *   The request response.
+   *
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   *
+   * @see \GuzzleHttp\ClientInterface::request
+   */
+  protected function request($method, Url $url, array $request_options) {
+    try {
+      $response = $this->httpClient->request($method, $url->toString(), $request_options);
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+    }
+    catch (ServerException $e) {
+      $response = $e->getResponse();
+    }
+
+    return $response;
+  }
+
+  /**
+   * Creates default content to test the API.
+   *
+   * @param int $num_articles
+   *   Number of articles to create.
+   * @param int $num_tags
+   *   Number of tags to create.
+   * @param bool $article_has_image
+   *   Set to TRUE if you want to add an image to the generated articles.
+   * @param bool $article_has_link
+   *   Set to TRUE if you want to add a link to the generated articles.
+   * @param bool $is_multilingual
+   *   (optional) Set to TRUE if you want to enable multilingual content.
+   * @param bool $referencing_twice
+   *   (optional) Set to TRUE if you want articles to reference the same tag
+   *   twice.
+   */
+  protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link, $is_multilingual, $referencing_twice = FALSE) {
+    $random = $this->getRandomGenerator();
+    for ($created_tags = 0; $created_tags < $num_tags; $created_tags++) {
+      $term = Term::create([
+        'vid' => 'tags',
+        'name' => $random->name(),
+      ]);
+
+      if ($is_multilingual) {
+        $term->addTranslation('ca', ['name' => $term->getName() . ' (ca)']);
+      }
+
+      $term->save();
+      $this->tags[] = $term;
+    }
+    for ($created_nodes = 0; $created_nodes < $num_articles; $created_nodes++) {
+      $values = [
+        'uid' => ['target_id' => $this->user->id()],
+        'type' => 'article',
+      ];
+
+      if ($referencing_twice) {
+        $values['field_tags'] = [
+          ['target_id' => 1],
+          ['target_id' => 1],
+        ];
+      }
+      else {
+        // Get N random tags.
+        $selected_tags = mt_rand(1, $num_tags);
+        $tags = [];
+        while (count($tags) < $selected_tags) {
+          $tags[] = mt_rand(1, $num_tags);
+          $tags = array_unique($tags);
+        }
+        $values['field_tags'] = array_map(function ($tag) {
+          return ['target_id' => $tag];
+        }, $tags);
+      }
+      if ($article_has_image) {
+        $file = File::create([
+          'uri' => 'vfs://' . $random->name() . '.png',
+        ]);
+        $file->setPermanent();
+        $file->save();
+        $this->files[] = $file;
+        $values['field_image'] = ['target_id' => $file->id(), 'alt' => 'alt text'];
+      }
+      if ($article_has_link) {
+        $values['field_link'] = [
+          'title' => $this->getRandomGenerator()->name(),
+          'uri' => sprintf(
+            '%s://%s.%s',
+            'http' . (mt_rand(0, 2) > 1 ? '' : 's'),
+            $this->getRandomGenerator()->name(),
+            'org'
+          ),
+        ];
+      }
+
+      // Create values for the sort fields, to allow for testing complex
+      // sorting:
+      // - field_sort1 increments every 5 articles, starting at zero
+      // - field_sort2 decreases every article, ending at zero.
+      $values['field_sort1'] = ['value' => floor($created_nodes / 5)];
+      $values['field_sort2'] = ['value' => $num_articles - $created_nodes];
+
+      $node = $this->createNode($values);
+
+      if ($is_multilingual === static::IS_MULTILINGUAL) {
+        $values['title'] = $node->getTitle() . ' (ca)';
+        $values['field_image']['alt'] = 'alt text (ca)';
+        $node->addTranslation('ca', $values);
+      }
+      $node->save();
+
+      $this->nodes[] = $node;
+    }
+    if ($article_has_link) {
+      // Make sure that there is at least 1 https link for ::testRead() #19.
+      $this->nodes[0]->field_link = [
+        'title' => 'Drupal',
+        'uri' => 'https://drupal.org',
+      ];
+      $this->nodes[0]->save();
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
new file mode 100644
index 0000000000..5cc33890de
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -0,0 +1,847 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\comment\Entity\Comment;
+use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
+use Drupal\comment\Tests\CommentTestTrait;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Url;
+use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API regression tests.
+ *
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class JsonApiRegressionTest extends JsonApiFunctionalTestBase {
+
+  use CommentTestTrait;
+
+  /**
+   * Ensure filtering on relationships works with bundle-specific target types.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2953207
+   */
+  public function testBundleSpecificTargetEntityTypeFromIssue2953207() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['comment'], TRUE), 'Installed modules.');
+    $this->addDefaultCommentField('taxonomy_term', 'tags', 'comment', CommentItemInterface::OPEN, 'tcomment');
+    $this->rebuildAll();
+
+    // Create data.
+    Term::create([
+      'name' => 'foobar',
+      'vid' => 'tags',
+    ])->save();
+    Comment::create([
+      'subject' => 'Llama',
+      'entity_id' => 1,
+      'entity_type' => 'taxonomy_term',
+      'field_name' => 'comment',
+    ])->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access comments',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/comment/tcomment?include=entity_id&filter[entity_id.name]=foobar'), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+  /**
+   * Ensure deep nested include works on multi target entity type field.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2973681
+   */
+  public function testDeepNestedIncludeMultiTargetEntityTypeFieldFromIssue2973681() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['comment'], TRUE), 'Installed modules.');
+    $this->addDefaultCommentField('node', 'article');
+    $this->addDefaultCommentField('taxonomy_term', 'tags', 'comment', CommentItemInterface::OPEN, 'tcomment');
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->createEntityReferenceField(
+      'node',
+      'page',
+      'field_comment',
+      NULL,
+      'comment',
+      'default',
+      [
+        'target_bundles' => [
+          'comment' => 'comment',
+          'tcomment' => 'tcomment',
+        ],
+      ],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->rebuildAll();
+
+    // Create data.
+    $node = Node::create([
+      'title' => 'test article',
+      'type' => 'article',
+    ]);
+    $node->save();
+    $comment = Comment::create([
+      'subject' => 'Llama',
+      'entity_id' => 1,
+      'entity_type' => 'node',
+      'field_name' => 'comment',
+    ]);
+    $comment->save();
+    $page = Node::create([
+      'title' => 'test node',
+      'type' => 'page',
+      'field_comment' => [
+        'entity' => $comment,
+      ],
+    ]);
+    $page->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access content',
+      'access comments',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/page?include=field_comment,field_comment.entity_id,field_comment.entity_id.uid'), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+  /**
+   * Ensure POST and PATCH works for bundle-less relationship routes.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2976371
+   */
+  public function testBundlelessRelationshipMutationFromIssue2973681() {
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->createEntityReferenceField(
+      'node',
+      'page',
+      'field_test',
+      NULL,
+      'user',
+      'default',
+      [
+        'target_bundles' => NULL,
+      ],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->rebuildAll();
+
+    // Create data.
+    $node = Node::create([
+      'title' => 'test article',
+      'type' => 'page',
+    ]);
+    $node->save();
+    $target = $this->createUser();
+
+    // Test.
+    $user = $this->drupalCreateUser(['bypass node access']);
+    $url = Url::fromRoute('jsonapi.node--page.field_test.relationship.post', ['entity' => $node->uuid()]);
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getUsername(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          ['type' => 'user--user', 'id' => $target->uuid()],
+        ],
+      ],
+    ];
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(204, $response->getStatusCode(), (string) $response->getBody());
+  }
+
+  /**
+   * Ensures GETting terms works when multiple vocabularies exist.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2977879
+   */
+  public function testGetTermWhenMultipleVocabulariesExistFromIssue2977879() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['taxonomy'], TRUE), 'Installed modules.');
+    Vocabulary::create([
+      'name' => 'one',
+      'vid' => 'one',
+    ])->save();
+    Vocabulary::create([
+      'name' => 'two',
+      'vid' => 'two',
+    ])->save();
+    $this->rebuildAll();
+
+    // Create data.
+    Term::create(['vid' => 'one'])
+      ->setName('Test')
+      ->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access content',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/taxonomy_term/one'), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+  /**
+   * Cannot PATCH an entity with dangling references in an ER field.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2968972
+   */
+  public function testDanglingReferencesInAnEntityReferenceFieldFromIssue2968972() {
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'journal_issue']);
+    $this->drupalCreateContentType(['type' => 'journal_article']);
+    $this->createEntityReferenceField(
+      'node',
+      'journal_article',
+      'field_issue',
+      NULL,
+      'node',
+      'default',
+      [
+        'target_bundles' => [
+          'journal_issue' => 'journal_issue',
+        ],
+      ],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->rebuildAll();
+
+    // Create data.
+    $issue_node = Node::create([
+      'title' => 'Test Journal Issue',
+      'type' => 'journal_issue',
+    ]);
+    $issue_node->save();
+
+    $user = $this->drupalCreateUser([
+      'access content',
+      'edit own journal_article content',
+    ]);
+    $article_node = Node::create([
+      'title' => 'Test Journal Article',
+      'type' => 'journal_article',
+      'field_issue' => [
+        'target_id' => $issue_node->id(),
+      ],
+    ]);
+    $article_node->setOwner($user);
+    $article_node->save();
+
+    // Test.
+    $url = Url::fromUri(sprintf('internal:/jsonapi/node/journal_article/%s', $article_node->uuid()));
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getUsername(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          'type' => 'node--journal_article',
+          'id' => $article_node->uuid(),
+          'attributes' => [
+            'title' => 'My New Article Title',
+          ],
+        ],
+      ],
+    ];
+    $issue_node->delete();
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody());
+  }
+
+  /**
+   * Ensures GETting node collection + hook_node_grants() implementations works.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2984964
+   */
+  public function testGetNodeCollectionWithHookNodeGrantsImplementationsFromIssue2984964() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['node_access_test'], TRUE), 'Installed modules.');
+    node_access_rebuild();
+    $this->rebuildAll();
+
+    // Create data.
+    Node::create([
+      'title' => 'test article',
+      'type' => 'article',
+    ])->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access content',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/article'), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertTrue(in_array('user.node_grants:view', explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]), TRUE));
+  }
+
+  /**
+   * Cannot GET an entity with dangling references in an ER field.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2984647
+   */
+  public function testDanglingReferencesInAnEntityReferenceFieldFromIssue2984647() {
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'journal_issue']);
+    $this->drupalCreateContentType(['type' => 'journal_conference']);
+    $this->drupalCreateContentType(['type' => 'journal_article']);
+    $this->createEntityReferenceField(
+      'node',
+      'journal_article',
+      'field_issue',
+      NULL,
+      'node',
+      'default',
+      [
+        'target_bundles' => [
+          'journal_issue' => 'journal_issue',
+        ],
+      ],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->createEntityReferenceField(
+      'node',
+      'journal_article',
+      'field_mentioned_in',
+      NULL,
+      'node',
+      'default',
+      [
+        'target_bundles' => [
+          'journal_issue' => 'journal_issue',
+          'journal_conference' => 'journal_conference',
+        ],
+      ],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->rebuildAll();
+
+    // Create data.
+    $issue_node = Node::create([
+      'title' => 'Test Journal Issue',
+      'type' => 'journal_issue',
+    ]);
+    $issue_node->save();
+    $conference_node = Node::create([
+      'title' => 'First Journal Conference!',
+      'type' => 'journal_conference',
+    ]);
+    $conference_node->save();
+
+    $user = $this->drupalCreateUser([
+      'access content',
+      'edit own journal_article content',
+    ]);
+    $article_node = Node::create([
+      'title' => 'Test Journal Article',
+      'type' => 'journal_article',
+      'field_issue' => [
+        ['target_id' => $issue_node->id()],
+      ],
+      'field_mentioned_in' => [
+        ['target_id' => $issue_node->id()],
+        ['target_id' => $conference_node->id()],
+      ],
+    ]);
+    $article_node->setOwner($user);
+    $article_node->save();
+
+    // Test.
+    $url = Url::fromUri(sprintf('internal:/jsonapi/node/journal_article/%s', $article_node->uuid()));
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getUsername(), $user->pass_raw],
+    ];
+    $issue_node->delete();
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+
+    // Entity reference field allowing a single bundle: dangling reference's
+    // resource type is deduced.
+    $this->assertSame([
+      [
+        'type' => 'node--journal_issue',
+        'id' => 'missing',
+        'meta' => [
+          'links' => [
+            'help' => [
+              'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#missing',
+              'meta' => [
+                'about' => "Usage and meaning of the 'missing' resource identifier.",
+              ],
+            ],
+          ],
+        ],
+      ],
+    ], Json::decode((string) $response->getBody())['data']['relationships']['field_issue']['data']);
+
+    // Entity reference field allowing multiple bundles: dangling reference's
+    // resource type is NOT deduced.
+    $this->assertSame([
+      [
+        'type' => 'unknown',
+        'id' => 'missing',
+        'meta' => [
+          'links' => [
+            'help' => [
+              'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#missing',
+              'meta' => [
+                'about' => "Usage and meaning of the 'missing' resource identifier.",
+              ],
+            ],
+          ],
+        ],
+      ],
+      [
+        'type' => 'node--journal_conference',
+        'id' => $conference_node->uuid(),
+      ],
+    ], Json::decode((string) $response->getBody())['data']['relationships']['field_mentioned_in']['data']);
+  }
+
+  /**
+   * Ensures that JSON:API routes are caches are dynamically rebuilt.
+   *
+   * Adding a new relationship field should cause new routes to be immediately
+   * regenerated. The site builder should not need to manually rebuild caches.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2984886
+   */
+  public function testThatRoutesAreRebuiltAfterDataModelChangesFromIssue2984886() {
+    $user = $this->drupalCreateUser(['access content']);
+    $request_options = [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ];
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/dog'), $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+
+    $node_type_dog = NodeType::create(['type' => 'dog']);
+    $node_type_dog->save();
+    NodeType::create(['type' => 'cat'])->save();
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/dog'), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+
+    $this->createEntityReferenceField('node', 'dog', 'field_test', NULL, 'node');
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $dog = Node::create(['type' => 'dog', 'title' => 'Rosie P. Mosie']);
+    $dog->save();
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/dog/' . $dog->uuid() . '/field_test'), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+
+    $this->createEntityReferenceField('node', 'cat', 'field_test', NULL, 'node');
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $cat = Node::create(['type' => 'cat', 'title' => 'E. Napoleon']);
+    $cat->save();
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/cat/' . $cat->uuid() . '/field_test'), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+
+    FieldConfig::loadByName('node', 'cat', 'field_test')->delete();
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/cat/' . $cat->uuid() . '/field_test'), $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+
+    $node_type_dog->delete();
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/dog'), $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+  }
+
+  /**
+   * Ensures denormalizing relationships with aliased field names works.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3007113
+   * @see https://www.drupal.org/project/jsonapi_extras/issues/3004582#comment-12817261
+   */
+  public function testDenormalizeAliasedRelationshipFromIssue2953207() {
+    // Since the JSON:API module does not have an explicit mechanism to set up
+    // field aliases, create a strange data model so that automatic aliasing
+    // allows us to test aliased relationships.
+    // @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+    $internal_relationship_field_name = 'type';
+    $public_relationship_field_name = 'taxonomy_term_' . $internal_relationship_field_name;
+
+    // Set up data model.
+    $this->createEntityReferenceField(
+      'taxonomy_term',
+      'tags',
+      $internal_relationship_field_name,
+      NULL,
+      'user'
+    );
+    $this->rebuildAll();
+
+    // Create data.
+    Term::create([
+      'name' => 'foobar',
+      'vid' => 'tags',
+      'type' => ['target_id' => 1],
+    ])->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'edit terms in tags',
+    ]);
+    $body = [
+      'data' => [
+        'type' => 'user--user',
+        'id' => User::load(0)->uuid(),
+      ],
+    ];
+
+    // Test.
+    $response = $this->request('PATCH', Url::fromUri(sprintf('internal:/jsonapi/taxonomy_term/tags/%s/relationships/%s', Term::load(1)->uuid(), $public_relationship_field_name)), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+      ],
+      RequestOptions::BODY => Json::encode($body),
+    ]);
+    $this->assertSame(204, $response->getStatusCode());
+  }
+
+  /**
+   * Ensures that Drupal's page cache is effective.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3009596
+   */
+  public function testPageCacheFromIssue3009596() {
+    $anonymous_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    $anonymous_role->grantPermission('access content');
+    $anonymous_role->trustData()->save();
+
+    NodeType::create(['type' => 'emu_fact'])->save();
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    $node = Node::create([
+      'type' => 'emu_fact',
+      'title' => "Emus don't say moo!",
+    ]);
+    $node->save();
+
+    $request_options = [
+      RequestOptions::HEADERS => ['Accept' => 'application/vnd.api+json'],
+    ];
+    $node_url = Url::fromUri('internal:/jsonapi/node/emu_fact/' . $node->uuid());
+
+    // The first request should be a cache MISS.
+    $response = $this->request('GET', $node_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertSame('MISS', $response->getHeader('X-Drupal-Cache')[0]);
+
+    // The second request should be a cache HIT.
+    $response = $this->request('GET', $node_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertSame('HIT', $response->getHeader('X-Drupal-Cache')[0]);
+  }
+
+  /**
+   * Ensures that filtering by a sequential internal ID named 'id' is possible.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3015759
+   */
+  public function testFilterByIdFromIssue3015759() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['shortcut'], TRUE), 'Installed modules.');
+    $this->rebuildAll();
+
+    // Create data.
+    $shortcut = Shortcut::create([
+      'shortcut_set' => 'default',
+      'title' => $this->randomMachineName(),
+      'weight' => -20,
+      'link' => [
+        'uri' => 'internal:/user/logout',
+      ],
+    ]);
+    $shortcut->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access shortcuts',
+      'customize shortcut links',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/shortcut/default?filter[drupal_internal__id]=' . $shortcut->id()), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertNotEmpty($doc['data']);
+    $this->assertSame($doc['data'][0]['id'], $shortcut->uuid());
+    $this->assertSame($doc['data'][0]['attributes']['drupal_internal__id'], (int) $shortcut->id());
+    $this->assertSame($doc['data'][0]['attributes']['title'], $shortcut->label());
+  }
+
+  /**
+   * Ensures datetime fields are normalized using the correct timezone.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/2999438
+   */
+  public function testPatchingDateTimeNormalizedWrongTimeZoneIssue3021194() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['datetime'], TRUE), 'Installed modules.');
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->rebuildAll();
+    FieldStorageConfig::create([
+      'field_name' => 'when',
+      'type' => 'datetime',
+      'entity_type' => 'node',
+      'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME],
+    ])
+      ->save();
+    FieldConfig::create([
+      'field_name' => 'when',
+      'entity_type' => 'node',
+      'bundle' => 'page',
+    ])
+      ->save();
+
+    // Create data.
+    $page = Node::create([
+      'title' => 'Stegosaurus',
+      'type' => 'page',
+      'when' => [
+        'value' => '2018-09-16T12:00:00',
+      ],
+    ]);
+    $page->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access content',
+    ]);
+    $response = $this->request('GET', Url::fromUri('internal:/jsonapi/node/page/' . $page->uuid()), [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+    ]);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertSame('2018-09-16T22:00:00+10:00', $doc['data']['attributes']['when']);
+  }
+
+  /**
+   * Ensures PATCHing datetime (both date-only & date+time) fields is possible.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3021194
+   */
+  public function testPatchingDateTimeFieldsFromIssue3021194() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['datetime'], TRUE), 'Installed modules.');
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->rebuildAll();
+    FieldStorageConfig::create([
+      'field_name' => 'when',
+      'type' => 'datetime',
+      'entity_type' => 'node',
+      'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATE],
+    ])
+      ->save();
+    FieldConfig::create([
+      'field_name' => 'when',
+      'entity_type' => 'node',
+      'bundle' => 'page',
+    ])
+      ->save();
+    FieldStorageConfig::create([
+      'field_name' => 'when_exactly',
+      'type' => 'datetime',
+      'entity_type' => 'node',
+      'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME],
+    ])
+      ->save();
+    FieldConfig::create([
+      'field_name' => 'when_exactly',
+      'entity_type' => 'node',
+      'bundle' => 'page',
+    ])
+      ->save();
+
+    // Create data.
+    $page = Node::create([
+      'title' => 'Stegosaurus',
+      'type' => 'page',
+      'when' => [
+        'value' => '2018-12-19',
+      ],
+      'when_exactly' => [
+        'value' => '2018-12-19T17:00:00',
+      ],
+    ]);
+    $page->save();
+
+    // Test.
+    $user = $this->drupalCreateUser([
+      'access content',
+      'edit any page content',
+    ]);
+    $request_options = [
+      RequestOptions::AUTH => [
+        $user->getUsername(),
+        $user->pass_raw,
+      ],
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+    ];
+    $node_url = Url::fromUri('internal:/jsonapi/node/page/' . $page->uuid());
+    $response = $this->request('GET', $node_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertSame('2018-12-19', $doc['data']['attributes']['when']);
+    $this->assertSame('2018-12-20T04:00:00+11:00', $doc['data']['attributes']['when_exactly']);
+    $doc['data']['attributes']['when'] = '2018-12-20';
+    $doc['data']['attributes']['when_exactly'] = '2018-12-19T19:00:00+01:00';
+    $request_options = $request_options + [RequestOptions::JSON => $doc];
+    $response = $this->request('PATCH', $node_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertSame('2018-12-20', $doc['data']['attributes']['when']);
+    $this->assertSame('2018-12-20T05:00:00+11:00', $doc['data']['attributes']['when_exactly']);
+  }
+
+  /**
+   * Ensure includes are respected even when POSTing.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3026030
+   */
+  public function testPostToIncludeUrlDoesNotReturnIncludeFromIssue3026030() {
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->rebuildAll();
+
+    // Test.
+    $user = $this->drupalCreateUser(['bypass node access']);
+    $url = Url::fromUri('internal:/jsonapi/node/page?include=uid');
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getUsername(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          'type' => 'node--page',
+          'attributes' => [
+            'title' => 'test',
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(201, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertArrayHasKey('included', $doc);
+    $this->assertSame($user->label(), $doc['included'][0]['attributes']['name']);
+  }
+
+  /**
+   * Ensure includes are respected even when PATCHing.
+   *
+   * @see https://www.drupal.org/project/jsonapi/issues/3026030
+   */
+  public function testPatchToIncludeUrlDoesNotReturnIncludeFromIssue3026030() {
+    // Set up data model.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $this->rebuildAll();
+
+    // Create data.
+    $user = $this->drupalCreateUser(['bypass node access']);
+    $page = Node::create([
+      'title' => 'original',
+      'type' => 'page',
+      'uid' => $user->id(),
+    ]);
+    $page->save();
+
+    // Test.
+    $url = Url::fromUri(sprintf('internal:/jsonapi/node/page/%s/?include=uid', $page->uuid()));
+    $request_options = [
+      RequestOptions::HEADERS => [
+        'Content-Type' => 'application/vnd.api+json',
+        'Accept' => 'application/vnd.api+json',
+      ],
+      RequestOptions::AUTH => [$user->getUsername(), $user->pass_raw],
+      RequestOptions::JSON => [
+        'data' => [
+          'type' => 'node--page',
+          'id' => $page->uuid(),
+          'attributes' => [
+            'title' => 'modified',
+          ],
+        ],
+      ],
+    ];
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertArrayHasKey('included', $doc);
+    $this->assertSame($user->label(), $doc['included'][0]['attributes']['name']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php
new file mode 100644
index 0000000000..7d92087550
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Behat\Mink\Driver\BrowserKitDriver;
+use Drupal\Core\Url;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * Boilerplate for JSON:API Functional tests' HTTP requests.
+ *
+ * @internal
+ */
+trait JsonApiRequestTestTrait {
+
+  /**
+   * 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.
+   *
+   * @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
+   *   The response.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function request($method, Url $url, array $request_options) {
+    $this->refreshVariables();
+    $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);
+  }
+
+  /**
+   * 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/MediaTest.php b/core/modules/jsonapi/tests/src/Functional/MediaTest.php
new file mode 100644
index 0000000000..30b4bb412f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MediaTest.php
@@ -0,0 +1,370 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+use Drupal\file\Entity\File;
+use Drupal\media\Entity\Media;
+use Drupal\media\Entity\MediaType;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * JSON:API integration test for the "Media" content entity type.
+ *
+ * @group jsonapi
+ */
+class MediaTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['media'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'media';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'media--camelids';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\media\MediaInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view media']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create camelids media', 'access content']);
+        break;
+
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['edit any camelids media']);
+        // @todo Remove this in https://www.drupal.org/node/2824851.
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['delete any camelids media']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpRevisionAuthorization($method) {
+    parent::setUpRevisionAuthorization($method);
+    $this->grantPermissionsToTestedRole(['view all media revisions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!MediaType::load('camelids')) {
+      // Create a "Camelids" media type.
+      $media_type = MediaType::create([
+        'name' => 'Camelids',
+        'id' => 'camelids',
+        'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+        'source' => 'file',
+      ]);
+      $media_type->save();
+      // Create the source field.
+      $source_field = $media_type->getSource()->createSourceField($media_type);
+      $source_field->getFieldStorageDefinition()->save();
+      $source_field->save();
+      $media_type
+        ->set('source_configuration', [
+          'source_field' => $source_field->getName(),
+        ])
+        ->save();
+    }
+
+    // Create a file to upload.
+    $file = File::create([
+      'uri' => 'public://llama.txt',
+    ]);
+    $file->setPermanent();
+    $file->save();
+
+    // @see \Drupal\Tests\jsonapi\Functional\MediaTest::testPostIndividual()
+    $post_file = File::create([
+      'uri' => 'public://llama2.txt',
+    ]);
+    $post_file->setPermanent();
+    $post_file->save();
+
+    // Create a "Llama" media item.
+    $media = Media::create([
+      'bundle' => 'camelids',
+      'field_media_file' => [
+        'target_id' => $file->id(),
+      ],
+    ]);
+    $media
+      ->setName('Llama')
+      ->setPublished()
+      ->setCreatedTime(123456789)
+      ->setOwnerId($this->account->id())
+      ->setRevisionUserId($this->account->id())
+      ->save();
+
+    return $media;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $file = File::load(1);
+    $thumbnail = File::load(3);
+    $author = User::load($this->entity->getOwnerId());
+    $self_url = Url::fromUri('base:/jsonapi/media/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'media--camelids',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'status' => TRUE,
+          'created' => '1973-11-29T21:33:09+00:00',
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'revision_created' => (new \DateTime())->setTimestamp($this->entity->getRevisionCreationTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'default_langcode' => TRUE,
+          'revision_log_message' => NULL,
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'drupal_internal__mid' => 1,
+          'drupal_internal__vid' => 1,
+        ],
+        'relationships' => [
+          'field_media_file' => [
+            'data' => [
+              'id' => $file->uuid(),
+              'meta' => [
+                'description' => NULL,
+                'display' => NULL,
+              ],
+              'type' => 'file--file',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/field_media_file'],
+              'self' => [
+                'href' => $self_url . '/relationships/field_media_file',
+              ],
+            ],
+          ],
+          'thumbnail' => [
+            'data' => [
+              'id' => $thumbnail->uuid(),
+              'meta' => [
+                'alt' => '',
+                'width' => 180,
+                'height' => 180,
+                'title' => NULL,
+              ],
+              'type' => 'file--file',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/thumbnail'],
+              'self' => ['href' => $self_url . '/relationships/thumbnail'],
+            ],
+          ],
+          'bundle' => [
+            'data' => [
+              'id' => MediaType::load('camelids')->uuid(),
+              'type' => 'media_type--media_type',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/bundle'],
+              'self' => ['href' => $self_url . '/relationships/bundle'],
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/uid'],
+              'self' => ['href' => $self_url . '/relationships/uid'],
+            ],
+          ],
+          'revision_user' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/revision_user'],
+              'self' => ['href' => $self_url . '/relationships/revision_user'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    $file = File::load(2);
+    return [
+      'data' => [
+        'type' => 'media--camelids',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+        'relationships' => [
+          'field_media_file' => [
+            'data' => [
+              'id' => $file->uuid(),
+              'meta' => [
+                'description' => 'This file is better!',
+                'display' => NULL,
+              ],
+              'type' => 'file--file',
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET';
+        return "The 'view media' permission is required and the media item must be published.";
+
+      case 'POST':
+        return "The following permissions are required: 'administer media' OR 'create media' OR 'create camelids media'.";
+
+      case 'PATCH':
+        return "The following permissions are required: 'update any media' OR 'update own media' OR 'camelids: edit any media' OR 'camelids: edit own media'.";
+
+      case 'DELETE':
+        return "The following permissions are required: 'delete any media' OR 'delete own media' OR 'camelids: delete any media' OR 'camelids: delete own media'.";
+
+      default:
+        return '';
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditorialPermissions() {
+    return array_merge(parent::getEditorialPermissions(), ['view any unpublished content']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\media\MediaAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['media:1']);
+  }
+
+  // @codingStandardsIgnoreStart
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    // @todo Mimic \Drupal\Tests\rest\Functional\EntityResource\Media\MediaResourceTestBase::testPost()
+    // @todo Later, use https://www.drupal.org/project/jsonapi/issues/2958554 to upload files rather than the REST module.
+    parent::testPostIndividual();
+  }
+  // @codingStandardsIgnoreEnd
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo Determine if this override should be removed in https://www.drupal.org/project/jsonapi/issues/2952522
+   */
+  protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
+    $data = parent::getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
+    switch ($relationship_field_name) {
+      case 'thumbnail':
+        $data['meta'] = [
+          'alt' => '',
+          'width' => 180,
+          'height' => 180,
+          'title' => NULL,
+        ];
+        return $data;
+
+      case 'field_media_file':
+        $data['meta'] = [
+          'description' => NULL,
+          'display' => NULL,
+        ];
+        return $data;
+
+      default:
+        return $data;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo Remove this in https://www.drupal.org/node/2824851.
+   */
+  protected function doTestRelationshipMutation(array $request_options) {
+    $this->grantPermissionsToTestedRole(['access content']);
+    parent::doTestRelationshipMutation($request_options);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $this->doTestCollectionFilterAccessForPublishableEntities('name', 'view media', 'administer media');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/MediaTypeTest.php b/core/modules/jsonapi/tests/src/Functional/MediaTypeTest.php
new file mode 100644
index 0000000000..3e2a0151ec
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MediaTypeTest.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\media\Entity\MediaType;
+
+/**
+ * JSON:API integration test for the "MediaType" config entity type.
+ *
+ * @group jsonapi
+ */
+class MediaTypeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['media'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'media_type';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'media_type--media_type';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\media\MediaTypeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer media types']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" media type.
+    $camelids = MediaType::create([
+      'name' => 'Camelids',
+      'id' => 'camelids',
+      'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+      'source' => 'file',
+    ]);
+
+    $camelids->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/media_type/media_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'media_type--media_type',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+          'field_map' => [],
+          'label' => NULL,
+          'langcode' => 'en',
+          'new_revision' => FALSE,
+          'queue_thumbnail_downloads' => FALSE,
+          'source' => 'file',
+          'source_configuration' => [
+            'source_field' => '',
+          ],
+          'status' => TRUE,
+          'drupal_internal__id' => 'camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/MenuLinkContentTest.php b/core/modules/jsonapi/tests/src/Functional/MenuLinkContentTest.php
new file mode 100644
index 0000000000..2e6b04c5ee
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MenuLinkContentTest.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\menu_link_content\Entity\MenuLinkContent;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+
+/**
+ * JSON:API integration test for the "MenuLinkContent" content entity type.
+ *
+ * @group jsonapi
+ */
+class MenuLinkContentTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['menu_link_content'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'menu_link_content';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'menu_link_content--menu_link_content';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\menu_link_content\MenuLinkContentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer menu']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $menu_link = MenuLinkContent::create([
+      'id' => 'llama',
+      'title' => 'Llama Gabilondo',
+      'description' => 'Llama Gabilondo',
+      'link' => 'https://nl.wikipedia.org/wiki/Llama',
+      'weight' => 0,
+      'menu_name' => 'main',
+    ]);
+    $menu_link->save();
+
+    return $menu_link;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/menu_link_content/menu_link_content/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'menu_link_content--menu_link_content',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'menu_link_content',
+          'link' => [
+            'uri' => 'https://nl.wikipedia.org/wiki/Llama',
+            'title' => NULL,
+            'options' => [],
+          ],
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'default_langcode' => TRUE,
+          'description' => 'Llama Gabilondo',
+          'enabled' => TRUE,
+          'expanded' => FALSE,
+          'external' => FALSE,
+          'langcode' => 'en',
+          'menu_name' => 'main',
+          'parent' => NULL,
+          'rediscover' => FALSE,
+          'title' => 'Llama Gabilondo',
+          'weight' => 0,
+          'drupal_internal__id' => 1,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'menu_link_content--menu_link_content',
+        'attributes' => [
+          'title' => 'Dramallama',
+          'link' => [
+            'uri' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'DELETE':
+        return "The 'administer menu' permission is required.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $this->doTestCollectionFilterAccessBasedOnPermissions('title', 'administer menu');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/MenuTest.php b/core/modules/jsonapi/tests/src/Functional/MenuTest.php
new file mode 100644
index 0000000000..2acb15f01a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MenuTest.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\system\Entity\Menu;
+
+/**
+ * JSON:API integration test for the "Menu" config entity type.
+ *
+ * @group jsonapi
+ */
+class MenuTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'menu';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'menu--menu';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $anonymousUsersCanViewLabels = TRUE;
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\system\MenuInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer menu']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $menu = Menu::create([
+      'id' => 'menu',
+      'label' => 'Menu',
+      'description' => 'Menu',
+    ]);
+    $menu->save();
+
+    return $menu;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/menu/menu/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'menu--menu',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Menu',
+          'label' => 'Menu',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'status' => TRUE,
+          'drupal_internal__id' => 'menu',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/MessageTest.php b/core/modules/jsonapi/tests/src/Functional/MessageTest.php
new file mode 100644
index 0000000000..48b82ed2b1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MessageTest.php
@@ -0,0 +1,189 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\contact\Entity\ContactForm;
+use Drupal\contact\Entity\Message;
+use Drupal\Core\Url;
+use GuzzleHttp\RequestOptions;
+use Symfony\Component\Routing\Exception\RouteNotFoundException;
+
+/**
+ * JSON:API integration test for the "Message" content entity type.
+ *
+ * @group jsonapi
+ */
+class MessageTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['contact'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'contact_message';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'contact_message--camelids';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\contact\MessageInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $labelFieldName = 'subject';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['access site-wide contact form']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!ContactForm::load('camelids')) {
+      // Create a "Camelids" contact form.
+      ContactForm::create([
+        'id' => 'camelids',
+        'label' => 'Llama',
+        'message' => 'Let us know what you think about llamas',
+        'reply' => 'Llamas are indeed awesome!',
+        'recipients' => [
+          'llama@example.com',
+          'contact@example.com',
+        ],
+      ])->save();
+    }
+
+    $message = Message::create([
+      'contact_form' => 'camelids',
+      'subject' => 'Llama Gabilondo',
+      'message' => 'Llamas are awesome!',
+    ]);
+    $message->save();
+
+    return $message;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    throw new \Exception('Not yet supported.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'contact_message--camelids',
+        'attributes' => [
+          'subject' => 'Dramallama',
+          'message' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($method === 'POST') {
+      return "The 'access site-wide contact form' permission is required.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetIndividual() {
+    // Contact Message entities are not stored, so they cannot be retrieved.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.individual" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    // Contact Message entities are not stored, so they cannot be modified.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.individual" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDeleteIndividual() {
+    // Contact Message entities are not stored, so they cannot be deleted.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.individual" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    // Contact Message entities are not stored, so they cannot be retrieved.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.related" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.related')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelationships() {
+    // Contact Message entities are not stored, so they cannot be retrieved.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.relationship.get" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.relationship.get')->toString(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollection() {
+    $collection_url = Url::fromRoute('jsonapi.contact_message--camelids.collection.post')->setAbsolute(TRUE);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // 405 because Message entities are not stored, so they cannot be retrieved,
+    // yet the same URL can be used to POST them.
+    $response = $this->request('GET', $collection_url, $request_options);
+    $this->assertSame(405, $response->getStatusCode());
+    $this->assertSame(['POST'], $response->getHeader('Allow'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRevisions() {
+    // Contact Message entities are not stored, so they cannot be retrieved.
+    $this->setExpectedException(RouteNotFoundException::class, 'Route "jsonapi.contact_message--camelids.individual" does not exist.');
+
+    Url::fromRoute('jsonapi.contact_message--camelids.individual')->toString(TRUE);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
new file mode 100644
index 0000000000..6e2d2ce1d9
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -0,0 +1,423 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "Node" content entity type.
+ *
+ * @group jsonapi
+ */
+class NodeTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'node';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'node--camelids';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'revision_timestamp' => NULL,
+    'created' => "The 'administer nodes' permission is required.",
+    'changed' => NULL,
+    'promote' => "The 'administer nodes' permission is required.",
+    'sticky' => "The 'administer nodes' permission is required.",
+    'path' => "The following permissions are required: 'create url aliases' OR 'administer url aliases'.",
+    'revision_uid' => NULL,
+  ];
+
+  /**
+   * {@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 setUpRevisionAuthorization($method) {
+    parent::setUpRevisionAuthorization($method);
+    $this->grantPermissionsToTestedRole(['view all revisions']);
+  }
+
+  /**
+   * {@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()
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789)
+      ->setRevisionCreationTime(123456789)
+      ->set('path', '/llama')
+      ->save();
+
+    return $node;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $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' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node--camelids',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => '1973-11-29T21:33:09+00:00',
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'promote' => TRUE,
+          'revision_log' => NULL,
+          'revision_timestamp' => '1973-11-29T21:33:09+00:00',
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'status' => TRUE,
+          'sticky' => FALSE,
+          'title' => 'Llama',
+          'drupal_internal__nid' => 1,
+          'drupal_internal__vid' => 1,
+        ],
+        'relationships' => [
+          'node_type' => [
+            'data' => [
+              'id' => NodeType::load('camelids')->uuid(),
+              'type' => 'node_type--node_type',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/node_type'],
+              'self' => ['href' => $self_url . '/relationships/node_type'],
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/uid'],
+              'self' => ['href' => $self_url . '/relationships/uid'],
+            ],
+          ],
+          'revision_uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/revision_uid'],
+              'self' => ['href' => $self_url . '/relationships/revision_uid'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'node--camelids',
+        'attributes' => [
+          'title' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+      case 'PATCH':
+      case 'DELETE':
+        return "The 'access content' permission is required.";
+
+      case 'POST':
+        // @see \Drupal\node\NodeAccessControlHandler::createAccess() forbids access without providing a reason if the user doe
+        return '';
+    }
+  }
+
+  /**
+   * 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\jsonapi\Functional\TermTest::testPatchPath()
+   * @see \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase::testPatchPath()
+   */
+  public function testPatchPath() {
+    $this->setUpAuthorization('GET');
+    $this->setUpAuthorization('PATCH');
+
+    // @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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+
+    // GET node's current normalization.
+    $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions());
+    $normalization = Json::decode((string) $response->getBody());
+
+    // Change node's path alias.
+    $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
+
+    // Create node PATCH request.
+    $request_options = $this->getAuthenticationRequestOptions();
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // PATCH request: 403 when creating URL aliases unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (path). The following permissions are required: 'create url aliases' OR 'administer url aliases'.", $url, $response, '/data/attributes/path');
+
+    // 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 = Json::decode((string) $response->getBody());
+    $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetIndividual() {
+    parent::testGetIndividual();
+
+    // Unpublish node.
+    $this->entity->setUnpublished()->save();
+
+    // @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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = $this->getAuthenticationRequestOptions();
+
+    // 403 when accessing own unpublished node.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'jsonapi' => static::$jsonApiMember,
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to GET the selected resource.',
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(
+      403,
+      $expected_document,
+      $response,
+      ['4xx-response', 'http_response', 'node:1'],
+      ['url.query_args:resourceVersion', 'url.site', 'user.permissions'],
+      FALSE,
+      'MISS'
+    );
+    /* $this->assertResourceErrorResponse(403, 'The current user is not allowed to GET the selected resource.', $response, '/data'); */
+
+    // 200 after granting permission.
+    $this->grantPermissionsToTestedRole(['view own unpublished content']);
+    $response = $this->request('GET', $url, $request_options);
+    // The response varies by 'user', causing the 'user.permissions' cache
+    // context to be optimized away.
+    $expected_cache_contexts = Cache::mergeContexts($this->getExpectedCacheContexts(), ['user']);
+    $expected_cache_contexts = array_diff($expected_cache_contexts, ['user.permissions']);
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $expected_cache_contexts, FALSE, 'UNCACHEABLE');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getIncludePermissions() {
+    return [
+      'uid.node_type' => ['administer users'],
+      'uid.roles' => ['administer permissions'],
+    ];
+  }
+
+  /**
+   * Creating relationships to missing resources should be 404 per JSON:API 1.1.
+   *
+   * @see https://github.com/json-api/json-api/issues/1033
+   */
+  public function testPostNonExistingAuthor() {
+    $this->setUpAuthorization('POST');
+    $this->grantPermissionsToTestedRole(['administer nodes']);
+
+    $random_uuid = \Drupal::service('uuid')->generate();
+    $doc = $this->getPostDocument();
+    $doc['data']['relationships']['uid']['data'] = [
+      'type' => 'user--user',
+      'id' => $random_uuid,
+    ];
+
+    // Create node POST request.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName));
+    $request_options = $this->getAuthenticationRequestOptions();
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::BODY] = Json::encode($doc);
+
+    // POST request: 404 when adding relationships to non-existing resources.
+    $response = $this->request('POST', $url, $request_options);
+    $expected_document = [
+      'errors' => [
+        0 => [
+          'status' => 404,
+          'title' => 'Not Found',
+          'detail' => "The resource identified by `user--user:$random_uuid` (given as a relationship item) could not be found.",
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(404)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+        ],
+      ],
+      'jsonapi' => static::$jsonApiMember,
+    ];
+    $this->assertResourceResponse(404, $expected_document, $response);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $label_field_name = 'title';
+    $this->doTestCollectionFilterAccessForPublishableEntities($label_field_name, 'access content', 'bypass node access');
+
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    $collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    $this->revokePermissionsFromTestedRole(['bypass node access']);
+
+    // 0 results because the node is unpublished.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+
+    $this->grantPermissionsToTestedRole(['view own unpublished content']);
+
+    // 1 result because the current user is the owner of the unpublished node.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+
+    $this->entity->setOwnerId(0)->save();
+
+    // 0 results because the current user is no longer the owner.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+
+    // Assert bubbling of cacheability from query alter hook.
+    $this->assertTrue($this->container->get('module_installer')->install(['node_access_test'], TRUE), 'Installed modules.');
+    node_access_rebuild();
+    $this->rebuildAll();
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $this->assertTrue(in_array('user.node_grants:view', explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]), TRUE));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTypeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTypeTest.php
new file mode 100644
index 0000000000..eeeb4645ae
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTypeTest.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * JSON:API integration test for the "NodeType" config entity type.
+ *
+ * @group jsonapi
+ */
+class NodeTypeTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'node_type';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'node_type--node_type';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\node\NodeTypeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer content types', 'access content']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+      'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+    ]);
+
+    $camelids->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/node_type/node_type/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node_type--node_type',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+          'display_submitted' => TRUE,
+          'help' => NULL,
+          'langcode' => 'en',
+          'name' => 'Camelids',
+          'new_revision' => TRUE,
+          'preview_mode' => 1,
+          'status' => TRUE,
+          'drupal_internal__type' => 'camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The 'access content' permission is required.";
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/RdfMappingTest.php b/core/modules/jsonapi/tests/src/Functional/RdfMappingTest.php
new file mode 100644
index 0000000000..2570e9756e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RdfMappingTest.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\node\Entity\NodeType;
+use Drupal\rdf\Entity\RdfMapping;
+
+/**
+ * JSON:API integration test for the "RdfMapping" config entity type.
+ *
+ * @group jsonapi
+ */
+class RdfMappingTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'rdf'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'rdf_mapping';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'rdf_mapping--rdf_mapping';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\rdf\RdfMappingInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer site configuration']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    $camelids = NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ]);
+
+    $camelids->save();
+
+    // Create the RDF mapping.
+    $llama = RdfMapping::create([
+      'targetEntityType' => 'node',
+      'bundle' => 'camelids',
+    ]);
+    $llama->setBundleMapping([
+      'types' => ['sioc:Item', 'foaf:Document'],
+    ])
+      ->setFieldMapping('title', [
+        'properties' => ['dc:title'],
+      ])
+      ->setFieldMapping('created', [
+        'properties' => ['dc:date', 'dc:created'],
+        'datatype' => 'xsd:dateTime',
+        'datatype_callback' => ['callable' => 'Drupal\rdf\CommonDataConverter::dateIso8601Value'],
+      ])
+      ->save();
+
+    return $llama;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/rdf_mapping/rdf_mapping/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'rdf_mapping--rdf_mapping',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+            'module' => [
+              'node',
+            ],
+          ],
+          'fieldMappings' => [
+            'title' => [
+              'properties' => [
+                'dc:title',
+              ],
+            ],
+            'created' => [
+              'properties' => [
+                'dc:date',
+                'dc:created',
+              ],
+              'datatype' => 'xsd:dateTime',
+              'datatype_callback' => [
+                'callable' => 'Drupal\rdf\CommonDataConverter::dateIso8601Value',
+              ],
+            ],
+          ],
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'node',
+          'types' => [
+            'sioc:Item',
+            'foaf:Document',
+          ],
+          'drupal_internal__id' => 'node.camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
new file mode 100644
index 0000000000..bc051cc657
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -0,0 +1,657 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\jsonapi\ResourceResponse;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Utility methods for handling resource responses.
+ *
+ * @internal
+ */
+trait ResourceResponseTestTrait {
+
+  /**
+   * Merges individual responses into a collection response.
+   *
+   * Here, a collection response refers to a response with multiple resource
+   * objects. Not necessarily to a response to a collection route. In both
+   * cases, the document should indistinguishable.
+   *
+   * @param \Drupal\jsonapi\ResourceResponse[] $responses
+   *   An array or ResourceResponses to be merged.
+   * @param string|null $self_link
+   *   The self link for the merged document if one should be set.
+   * @param bool $is_multiple
+   *   Whether the responses are for a multiple cardinality field. This cannot
+   *   be deduced from the number of responses, because a multiple cardinality
+   *   field may have only one value.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The merged ResourceResponse.
+   */
+  protected static function toCollectionResourceResponse(array $responses, $self_link, $is_multiple) {
+    assert(count($responses) > 0);
+    $merged_document = [];
+    $merged_cacheability = new CacheableMetadata();
+    foreach ($responses as $response) {
+      $response_document = $response->getResponseData();
+      // If any of the response documents had top-level errors, we should later
+      // expect the merged document to have all errors as omitted links under
+      // the 'meta.omitted' member.
+      if (!empty($response_document['errors'])) {
+        static::addOmittedObject($merged_document, static::errorsToOmittedObject($response_document['errors']));
+      }
+      if (!empty($response_document['meta']['omitted'])) {
+        static::addOmittedObject($merged_document, $response_document['meta']['omitted']);
+      }
+      elseif (isset($response_document['data'])) {
+        $response_data = $response_document['data'];
+        if (!isset($merged_document['data'])) {
+          $merged_document['data'] = static::isResourceIdentifier($response_data) && $is_multiple
+            ? [$response_data]
+            : $response_data;
+        }
+        else {
+          $response_resources = static::isResourceIdentifier($response_data)
+            ? [$response_data]
+            : $response_data;
+          foreach ($response_resources as $response_resource) {
+            $merged_document['data'][] = $response_resource;
+          }
+        }
+      }
+      $merged_cacheability->addCacheableDependency($response->getCacheableMetadata());
+    }
+    $merged_document['jsonapi'] = [
+      'meta' => [
+        'links' => [
+          'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+        ],
+      ],
+      'version' => '1.0',
+    ];
+    // Until we can reasonably know what caused an error, we shouldn't include
+    // 'self' links in error documents. For example, a 404 shouldn't have a
+    // 'self' link because HATEOAS links shouldn't point to resources which do
+    // not exist.
+    if (isset($merged_document['errors'])) {
+      unset($merged_document['links']);
+    }
+    else {
+      if (!isset($merged_document['data'])) {
+        $merged_document['data'] = $is_multiple ? [] : NULL;
+      }
+      $merged_document['links'] = [
+        'self' => [
+          'href' => $self_link,
+        ],
+      ];
+    }
+    // All collections should be 200, without regard for the status of the
+    // individual resources in those collections, which means any '4xx-response'
+    // cache tags on the individual responses should also be omitted.
+    $merged_cacheability->setCacheTags(array_diff($merged_cacheability->getCacheTags(), ['4xx-response']));
+    return (new ResourceResponse($merged_document, 200))->addCacheableDependency($merged_cacheability);
+  }
+
+  /**
+   * Gets an array of expected ResourceResponses for the given include paths.
+   *
+   * @param array $include_paths
+   *   The list of relationship include paths for which to get expected data.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The expected ResourceResponse.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getExpectedIncludedResourceResponse(array $include_paths, array $request_options) {
+    $resource_type = $this->resourceType;
+    $resource_data = array_reduce($include_paths, function ($data, $path) use ($request_options, $resource_type) {
+      $field_names = explode('.', $path);
+      /* @var \Drupal\Core\Entity\EntityInterface $entity */
+      $entity = $this->entity;
+      $collected_responses = [];
+      foreach ($field_names as $public_field_name) {
+        $resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity->getEntityTypeId(), $entity->bundle());
+        $field_name = $resource_type->getInternalName($public_field_name);
+        $field_access = static::entityFieldAccess($entity, $field_name, 'view', $this->account);
+        if (!$field_access->isAllowed()) {
+          if (!$entity->access('view') && $entity->access('view label') && $field_access instanceof AccessResultReasonInterface && empty($field_access->getReason())) {
+            $field_access->setReason("The user only has authorization for the 'view label' operation.");
+          }
+          $via_link = Url::fromRoute(
+            sprintf('jsonapi.%s.%s.related', $entity->getEntityTypeId() . '--' . $entity->bundle(), $public_field_name),
+            ['entity' => $entity->uuid()]
+          );
+          $collected_responses[] = static::getAccessDeniedResponse($entity, $field_access, $via_link, $field_name, 'The current user is not allowed to view this relationship.', $field_name);
+          break;
+        }
+        if ($target_entity = $entity->{$field_name}->entity) {
+          $target_access = static::entityAccess($target_entity, 'view', $this->account);
+          if (!$target_access->isAllowed()) {
+            $target_access = static::entityAccess($target_entity, 'view label', $this->account)->addCacheableDependency($target_access);
+          }
+          if (!$target_access->isAllowed()) {
+            $resource_identifier = static::toResourceIdentifier($target_entity);
+            if (!static::collectionHasResourceIdentifier($resource_identifier, $data['already_checked'])) {
+              $data['already_checked'][] = $resource_identifier;
+              $via_link = Url::fromRoute(
+                sprintf('jsonapi.%s.individual', $resource_identifier['type']),
+                ['entity' => $resource_identifier['id']]
+              );
+              $collected_responses[] = static::getAccessDeniedResponse($entity, $target_access, $via_link, NULL, NULL, '/data');
+            }
+            break;
+          }
+        }
+        $psr_responses = $this->getResponses([static::getRelatedLink(static::toResourceIdentifier($entity), $public_field_name)], $request_options);
+        $collected_responses[] = static::toCollectionResourceResponse(static::toResourceResponses($psr_responses), NULL, TRUE);
+        $entity = $entity->{$field_name}->entity;
+      }
+      if (!empty($collected_responses)) {
+        $data['responses'][$path] = static::toCollectionResourceResponse($collected_responses, NULL, TRUE);
+      }
+      return $data;
+    }, ['responses' => [], 'already_checked' => []]);
+
+    $individual_document = $this->getExpectedDocument();
+
+    $expected_base_url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
+    $include_url = clone $expected_base_url;
+    $query = ['include' => implode(',', $include_paths)];
+    $include_url->setOption('query', $query);
+    $individual_document['links']['self']['href'] = $include_url->toString();
+
+    // The test entity reference field should always be present.
+    if (!isset($individual_document['data']['relationships']['field_jsonapi_test_entity_ref'])) {
+      $individual_document['data']['relationships']['field_jsonapi_test_entity_ref'] = [
+        'data' => [],
+        'links' => [
+          'related' => [
+            'href' => $expected_base_url->toString() . '/field_jsonapi_test_entity_ref',
+          ],
+          'self' => [
+            'href' => $expected_base_url->toString() . '/relationships/field_jsonapi_test_entity_ref',
+          ],
+        ],
+      ];
+    }
+
+    $basic_cacheability = (new CacheableMetadata())
+      ->addCacheTags($this->getExpectedCacheTags())
+      ->addCacheContexts($this->getExpectedCacheContexts());
+    return static::decorateExpectedResponseForIncludedFields(ResourceResponse::create($individual_document), $resource_data['responses'])
+      ->addCacheableDependency($basic_cacheability);
+  }
+
+  /**
+   * Maps an array of PSR responses to JSON:API ResourceResponses.
+   *
+   * @param \Psr\Http\Message\ResponseInterface[] $responses
+   *   The PSR responses to be mapped.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse[]
+   *   The ResourceResponses.
+   */
+  protected static function toResourceResponses(array $responses) {
+    return array_map([self::class, 'toResourceResponse'], $responses);
+  }
+
+  /**
+   * Maps a response object to a JSON:API ResourceResponse.
+   *
+   * This helper can be used to ease comparing, recording and merging
+   * cacheable responses and to have easier access to the JSON:API document as
+   * an array instead of a string.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   A PSR response to be mapped.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The ResourceResponse.
+   */
+  protected static function toResourceResponse(ResponseInterface $response) {
+    $cacheability = new CacheableMetadata();
+    if ($cache_tags = $response->getHeader('X-Drupal-Cache-Tags')) {
+      $cacheability->addCacheTags(explode(' ', $cache_tags[0]));
+    }
+    if (!empty($response->getHeaderLine('X-Drupal-Cache-Contexts'))) {
+      $cacheability->addCacheContexts(explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
+    }
+    if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) {
+      $cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE' && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT);
+    }
+    $related_document = Json::decode($response->getBody());
+    $resource_response = new ResourceResponse($related_document, $response->getStatusCode());
+    return $resource_response->addCacheableDependency($cacheability);
+  }
+
+  /**
+   * Maps an entity to a resource identifier.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to map to a resource identifier.
+   *
+   * @return array
+   *   A resource identifier for the given entity.
+   */
+  protected static function toResourceIdentifier(EntityInterface $entity) {
+    return [
+      'type' => $entity->getEntityTypeId() . '--' . $entity->bundle(),
+      'id' => $entity->uuid(),
+    ];
+  }
+
+  /**
+   * Checks if a given array is a resource identifier.
+   *
+   * @param array $data
+   *   An array to check.
+   *
+   * @return bool
+   *   TRUE if the array has a type and ID, FALSE otherwise.
+   */
+  protected static function isResourceIdentifier(array $data) {
+    return array_key_exists('type', $data) && array_key_exists('id', $data);
+  }
+
+  /**
+   * Sorts a collection of resources or resource identifiers.
+   *
+   * This is useful for asserting collections or resources where order cannot
+   * be known in advance.
+   *
+   * @param array $resources
+   *   The resource or resource identifier.
+   */
+  protected static function sortResourceCollection(array &$resources) {
+    usort($resources, function ($a, $b) {
+      return strcmp("{$a['type']}:{$a['id']}", "{$b['type']}:{$b['id']}");
+    });
+  }
+
+  /**
+   * Determines if a given resource exists in a list of resources.
+   *
+   * @param array $needle
+   *   The resource or resource identifier.
+   * @param array $haystack
+   *   The list of resources or resource identifiers to search.
+   *
+   * @return bool
+   *   TRUE if the needle exists is present in the haystack, FALSE otherwise.
+   */
+  protected static function collectionHasResourceIdentifier(array $needle, array $haystack) {
+    foreach ($haystack as $resource) {
+      if ($resource['type'] == $needle['type'] && $resource['id'] == $needle['id']) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Turns a list of relationship field names into an array of link paths.
+   *
+   * @param array $relationship_field_names
+   *   The relationships field names for which to build link paths.
+   * @param string $type
+   *   The type of link to get. Either 'relationship' or 'related'.
+   *
+   * @return array
+   *   An array of link paths, keyed by relationship field name.
+   */
+  protected static function getLinkPaths(array $relationship_field_names, $type) {
+    assert($type === 'relationship' || $type === 'related');
+    return array_reduce($relationship_field_names, function ($link_paths, $relationship_field_name) use ($type) {
+      $tail = $type === 'relationship' ? 'self' : $type;
+      $link_paths[$relationship_field_name] = "data.relationships.$relationship_field_name.links.$tail.href";
+      return $link_paths;
+    }, []);
+  }
+
+  /**
+   * Extracts links from a document using a list of relationship field names.
+   *
+   * @param array $link_paths
+   *   A list of paths to link values keyed by a name.
+   * @param array $document
+   *   A JSON:API document.
+   *
+   * @return array
+   *   The extracted links, keyed by the original associated key name.
+   */
+  protected static function extractLinks(array $link_paths, array $document) {
+    return array_map(function ($link_path) use ($document) {
+      $link = array_reduce(
+        explode('.', $link_path),
+        'array_column',
+        [$document]
+      );
+      return ($link) ? reset($link) : NULL;
+    }, $link_paths);
+  }
+
+  /**
+   * Creates individual resource links for a list of resource identifiers.
+   *
+   * @param array $resource_identifiers
+   *   A list of resource identifiers for which to create links.
+   *
+   * @return string[]
+   *   The resource links.
+   */
+  protected static function getResourceLinks(array $resource_identifiers) {
+    return array_map([static::class, 'getResourceLink'], $resource_identifiers);
+  }
+
+  /**
+   * Creates an individual resource link for a given resource identifier.
+   *
+   * @param array $resource_identifier
+   *   A resource identifier for which to create a link.
+   *
+   * @return string
+   *   The resource link.
+   */
+  protected static function getResourceLink(array $resource_identifier) {
+    assert(static::isResourceIdentifier($resource_identifier));
+    $resource_type = $resource_identifier['type'];
+    $resource_id = $resource_identifier['id'];
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), ['entity' => $resource_id]);
+    return $url->setAbsolute()->toString();
+  }
+
+  /**
+   * Creates a relationship link for a given resource identifier and field.
+   *
+   * @param array $resource_identifier
+   *   A resource identifier for which to create a link.
+   * @param string $relationship_field_name
+   *   The relationship field for which to create a link.
+   *
+   * @return string
+   *   The relationship link.
+   */
+  protected static function getRelationshipLink(array $resource_identifier, $relationship_field_name) {
+    return static::getResourceLink($resource_identifier) . "/relationships/$relationship_field_name";
+  }
+
+  /**
+   * Creates a related resource link for a given resource identifier and field.
+   *
+   * @param array $resource_identifier
+   *   A resource identifier for which to create a link.
+   * @param string $relationship_field_name
+   *   The relationship field for which to create a link.
+   *
+   * @return string
+   *   The related resource link.
+   */
+  protected static function getRelatedLink(array $resource_identifier, $relationship_field_name) {
+    return static::getResourceLink($resource_identifier) . "/$relationship_field_name";
+  }
+
+  /**
+   * Gets an array of related responses for the given field names.
+   *
+   * @param array $relationship_field_names
+   *   The list of relationship field names for which to get responses.
+   * @param array $request_options
+   *   Request options to apply.
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get expected related responses.
+   *
+   * @return array
+   *   The related responses, keyed by relationship field names.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    $links = array_map(function ($relationship_field_name) use ($entity) {
+      return static::getRelatedLink(static::toResourceIdentifier($entity), $relationship_field_name);
+    }, array_combine($relationship_field_names, $relationship_field_names));
+    return $this->getResponses($links, $request_options);
+  }
+
+  /**
+   * Gets an array of relationship responses for the given field names.
+   *
+   * @param array $relationship_field_names
+   *   The list of relationship field names for which to get responses.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return array
+   *   The relationship responses, keyed by relationship field names.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getRelationshipResponses(array $relationship_field_names, array $request_options) {
+    $links = array_map(function ($relationship_field_name) {
+      return static::getRelationshipLink(static::toResourceIdentifier($this->entity), $relationship_field_name);
+    }, array_combine($relationship_field_names, $relationship_field_names));
+    return $this->getResponses($links, $request_options);
+  }
+
+  /**
+   * Gets responses from an array of links.
+   *
+   * @param array $links
+   *   A keyed array of links.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return array
+   *   The fetched array of responses, keys are preserved.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getResponses(array $links, array $request_options) {
+    return array_reduce(array_keys($links), function ($related_responses, $key) use ($links, $request_options) {
+      $related_responses[$key] = $this->request('GET', Url::fromUri($links[$key]), $request_options);
+      return $related_responses;
+    }, []);
+  }
+
+  /**
+   * Gets a generic forbidden response.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to generate the forbidden response.
+   * @param \Drupal\Core\Access\AccessResultInterface $access
+   *   The denied AccessResult. This can carry a reason and cacheability data.
+   * @param \Drupal\Core\Url $via_link
+   *   The source URL for the errors of the response.
+   * @param string|null $relationship_field_name
+   *   (optional) The field name to which the forbidden result applies. Useful
+   *   for testing related/relationship routes and includes.
+   * @param string|null $detail
+   *   (optional) Details for the JSON:API error object.
+   * @param string|bool|null $pointer
+   *   (optional) Document pointer for the JSON:API error object. FALSE to omit
+   *   the pointer.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The forbidden ResourceResponse.
+   */
+  protected static function getAccessDeniedResponse(EntityInterface $entity, AccessResultInterface $access, Url $via_link, $relationship_field_name = NULL, $detail = NULL, $pointer = NULL) {
+    $detail = ($detail) ? $detail : 'The current user is not allowed to GET the selected resource.';
+    if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) {
+      $detail .= ' ' . $reason;
+    }
+    $error = [
+      'status' => 403,
+      'title' => 'Forbidden',
+      'detail' => $detail,
+      'links' => [
+        'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+      ],
+    ];
+    if ($pointer || $pointer !== FALSE && $relationship_field_name) {
+      $error['source']['pointer'] = ($pointer) ? $pointer : $relationship_field_name;
+    }
+    if ($via_link) {
+      $error['links']['via']['href'] = $via_link->setAbsolute()->toString();
+    }
+
+    return (new ResourceResponse([
+      'jsonapi' => static::$jsonApiMember,
+      'errors' => [$error],
+    ], 403))
+      ->addCacheableDependency((new CacheableMetadata())->addCacheTags(['4xx-response', 'http_response'])->addCacheContexts(['url.site']))
+      ->addCacheableDependency($access);
+  }
+
+  /**
+   * Gets a generic empty collection response.
+   *
+   * @param int $cardinality
+   *   The cardinality of the resource collection. 1 for a to-one related
+   *   resource collection; -1 for an unlimited cardinality.
+   * @param string $self_link
+   *   The self link for collection ResourceResponse.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The empty collection ResourceResponse.
+   */
+  protected function getEmptyCollectionResponse($cardinality, $self_link) {
+    // If the entity type is revisionable, add a resource version cache context.
+    $cache_contexts = Cache::mergeContexts([
+      // Cache contexts for JSON:API URL query parameters.
+      'url.query_args:fields',
+      'url.query_args:include',
+      // Drupal defaults.
+      'url.site',
+    ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']);
+    return (new ResourceResponse([
+      // Empty to-one relationships should be NULL and empty to-many
+      // relationships should be an empty array.
+      'data' => $cardinality === 1 ? NULL : [],
+      'jsonapi' => static::$jsonApiMember,
+      'links' => ['self' => ['href' => $self_link]],
+    ]))->addCacheableDependency($cacheability);
+  }
+
+  /**
+   * Add the omitted object to the document or merges it if one already exists.
+   *
+   * @param array $document
+   *   The JSON:API response document.
+   * @param array $omitted
+   *   The omitted object.
+   */
+  protected static function addOmittedObject(array &$document, array $omitted) {
+    if (isset($document['meta']['omitted'])) {
+      $document['meta']['omitted'] = static::mergeOmittedObjects($document['meta']['omitted'], $omitted);
+    }
+    else {
+      $document['meta']['omitted'] = $omitted;
+    }
+  }
+
+  /**
+   * Maps error objects into an omitted object.
+   *
+   * @param array $errors
+   *   An array of error objects.
+   *
+   * @return array
+   *   A new omitted object.
+   */
+  protected static function errorsToOmittedObject(array $errors) {
+    $omitted = [
+      'detail' => 'Some resources have been omitted because of insufficient authorization.',
+      'links' => [
+        'help' => [
+          'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
+        ],
+      ],
+    ];
+    foreach ($errors as $error) {
+      $omitted['links']['item:' . substr(Crypt::hashBase64($error['links']['via']['href']), 0, 7)] = [
+        'href' => $error['links']['via']['href'],
+        'meta' => [
+          'detail' => $error['detail'],
+          'rel' => 'item',
+        ],
+      ];
+    }
+    return $omitted;
+  }
+
+  /**
+   * Merges the links of two omitted objects and returns a new omitted object.
+   *
+   * @param array $a
+   *   The first omitted object.
+   * @param array $b
+   *   The second omitted object.
+   *
+   * @return mixed
+   *   A new, merged omitted object.
+   */
+  protected static function mergeOmittedObjects(array $a, array $b) {
+    $merged['detail'] = 'Some resources have been omitted because of insufficient authorization.';
+    $merged['links']['help']['href'] = 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control';
+    $a_links = array_diff_key($a['links'], array_flip(['help']));
+    $b_links = array_diff_key($b['links'], array_flip(['help']));
+    foreach (array_merge(array_values($a_links), array_values($b_links)) as $link) {
+      $merged['links'][$link['href'] . $link['meta']['detail']] = $link;
+    }
+    static::resetOmittedLinkKeys($merged);
+    return $merged;
+  }
+
+  /**
+   * Sorts an omitted link object array by href.
+   *
+   * @param array $omitted
+   *   An array of JSON:API omitted link objects.
+   */
+  protected static function sortOmittedLinks(array &$omitted) {
+    $help = $omitted['links']['help'];
+    $links = array_diff_key($omitted['links'], array_flip(['help']));
+    uasort($links, function ($a, $b) {
+      return strcmp($a['href'], $b['href']);
+    });
+    $omitted['links'] = ['help' => $help] + $links;
+  }
+
+  /**
+   * Resets omitted link keys.
+   *
+   * Omitted link keys are a link relation type + a random string. This string
+   * is meaningless and only serves to differentiate link objects. Given that
+   * these are random, we can't assert their value.
+   *
+   * @param array $omitted
+   *   An array of JSON:API omitted link objects.
+   */
+  protected static function resetOmittedLinkKeys(array &$omitted) {
+    $help = $omitted['links']['help'];
+    $reindexed = [];
+    $links = array_diff_key($omitted['links'], array_flip(['help']));
+    foreach (array_values($links) as $index => $link) {
+      $reindexed['item:' . $index] = $link;
+    }
+    $omitted['links'] = ['help' => $help] + $reindexed;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000000..047ddd5972
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,3126 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\Random;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultReasonInterface;
+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\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityNullStorage;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\RevisionableInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\TypedData\DataReferenceTargetDefinition;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\Link;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\path\Plugin\Field\FieldType\PathItem;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\EntityOwnerInterface;
+use Drupal\user\RoleInterface;
+use Drupal\user\UserInterface;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Subclass this for every JSON:API resource type.
+ */
+abstract class ResourceTestBase extends BrowserTestBase {
+
+  use ResourceResponseTestTrait;
+  use ContentModerationTestTrait;
+  use JsonApiRequestTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'jsonapi',
+    'basic_auth',
+    'rest_test',
+    'jsonapi_test_field_access',
+    'text',
+  ];
+
+  /**
+   * 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 JSON:API resource type for the tested entity type plus bundle.
+   *
+   * Necessary for looking up public (alias) or internal (actual) field names.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The fields that are protected against modification during PATCH requests.
+   *
+   * @var string[]
+   */
+  protected static $patchProtectedFieldNames;
+
+  /**
+   * Fields that need unique values.
+   *
+   * @var string[]
+   *
+   * @see ::testPostIndividual()
+   * @see ::getModifiedEntityForPostTesting()
+   */
+  protected static $uniqueFieldNames = [];
+
+  /**
+   * The entity ID for the first created entity in testPost().
+   *
+   * The default value of 2 should work for most content entities.
+   *
+   * @var string|int
+   *
+   * @see ::testPostIndividual()
+   */
+  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.
+   *
+   * @var string|int
+   *
+   * @see ::testPostIndividual()
+   */
+  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.
+   *
+   * @var string|null
+   *
+   * @see ::getInvalidNormalizedEntityToCreate()
+   */
+  protected static $labelFieldName = NULL;
+
+  /**
+   * Whether anonymous users can view labels of this resource type.
+   *
+   * @var bool
+   */
+  protected static $anonymousUsersCanViewLabels = FALSE;
+
+  /**
+   * The standard `jsonapi` top-level document member.
+   *
+   * @var array
+   */
+  protected static $jsonApiMember = [
+    'version' => '1.0',
+    'meta' => [
+      'links' => ['self' => ['href' => 'http://jsonapi.org/format/1.0/']],
+    ],
+  ];
+
+  /**
+   * The entity being tested.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * Another entity of the same type used for testing.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $anotherEntity;
+
+  /**
+   * 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 UUID key.
+   *
+   * @var string
+   */
+  protected $uuidKey;
+
+  /**
+   * The serializer service.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('jsonapi.serializer');
+
+    // 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, which tests will use. Also ensure the @current_user
+    // service this account, to ensure certain access check logic in tests works
+    // as expected.
+    $this->account = $this->createUser();
+    $this->container->get('current_user')->setAccount($this->account);
+
+    // Create an entity.
+    $entity_type_manager = $this->container->get('entity_type.manager');
+    $this->entityStorage = $entity_type_manager->getStorage(static::$entityTypeId);
+    $this->uuidKey = $entity_type_manager->getDefinition(static::$entityTypeId)
+      ->getKey('uuid');
+    $this->entity = $this->setUpFields($this->createEntity(), $this->account);
+
+    $this->resourceType = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
+  }
+
+  /**
+   * Sets up additional fields for testing.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The primary test entity.
+   * @param \Drupal\user\UserInterface $account
+   *   The primary test user account.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The reloaded entity with the new fields attached.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  protected function setUpFields(EntityInterface $entity, UserInterface $account) {
+    if (!$entity instanceof FieldableEntityInterface) {
+      return $entity;
+    }
+
+    $entity_bundle = $entity->bundle();
+    $account_bundle = $account->bundle();
+
+    // 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' => $entity_bundle,
+    ])
+      ->setLabel('Test field')
+      ->setTranslatable(FALSE)
+      ->save();
+
+    FieldStorageConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_jsonapi_test_entity_ref',
+      'type' => 'entity_reference',
+    ])
+      ->setSetting('target_type', 'user')
+      ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED)
+      ->save();
+
+    FieldConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_jsonapi_test_entity_ref',
+      'bundle' => $entity_bundle,
+    ])
+      ->setTranslatable(FALSE)
+      ->setSetting('handler', 'default')
+      ->setSetting('handler_settings', [
+        'target_bundles' => NULL,
+      ])
+      ->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' => $entity_bundle,
+    ])
+      ->setLabel('Test field: multi-value')
+      ->setTranslatable(FALSE)
+      ->save();
+
+    \Drupal::service('router.builder')->rebuildIfNeeded();
+
+    // Reload entity so that it has the new field.
+    $reloaded_entity = $this->entityStorage->loadUnchanged($entity->id());
+    // Some entity types are not stored, hence they cannot be reloaded.
+    if ($reloaded_entity !== NULL) {
+      $entity = $reloaded_entity;
+
+      // Set a default value on the fields.
+      $entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+      $entity->set('field_jsonapi_test_entity_ref', ['user' => $account->id()]);
+      $entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
+      $entity->save();
+    }
+
+    return $entity;
+  }
+
+  /**
+   * Sets up a collection of entities of the same type for testing.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface[]
+   *   The collection of entities to test.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  protected function getEntityCollection() {
+    if ($this->entityStorage->getQuery()->count()->execute() < 2) {
+      $this->createAnotherEntity('two');
+    }
+    $query = $this->entityStorage->getQuery()->sort($this->entity->getEntityType()->getKey('id'));
+    return $this->entityStorage->loadMultiple($query->execute());
+  }
+
+  /**
+   * Generates a JSON:API normalization for the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to generate a JSON:API normalization for.
+   * @param \Drupal\Core\Url $url
+   *   The URL to use as the "self" link.
+   *
+   * @return array
+   *   The JSON:API normalization for the given entity.
+   */
+  protected function normalize(EntityInterface $entity, Url $url) {
+    $self_link = new Link(new CacheableMetadata(), $url, ['self']);
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
+    $doc = new JsonApiDocumentTopLevel(new ResourceObject($resource_type, $entity), new NullEntityCollection(), new LinkCollection(['self' => $self_link]));
+    return $this->serializer->normalize($doc, 'api_json', [
+      'resource_type' => $resource_type,
+      'account' => $this->account,
+    ])->getNormalization();
+  }
+
+  /**
+   * Creates the entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity to be tested.
+   */
+  abstract protected function createEntity();
+
+  /**
+   * Creates another entity to be tested.
+   *
+   * @param mixed $key
+   *   A unique key to be used for the ID and/or label of the duplicated entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   Another entity based on $this->entity.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  protected function createAnotherEntity($key) {
+    $duplicate = $this->getEntityDuplicate($this->entity, $key);
+    // Some entity types are not stored, hence they cannot be reloaded.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $duplicate->set('field_rest_test', 'Second collection entity');
+    }
+    $duplicate->save();
+    return $duplicate;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityDuplicate(EntityInterface $original, $key) {
+    $duplicate = $original->createDuplicate();
+    if ($label_key = $original->getEntityType()->getKey('label')) {
+      $duplicate->set($label_key, $original->label() . '_' . $key);
+    }
+    if ($duplicate instanceof ConfigEntityInterface && $id_key = $duplicate->getEntityType()->getKey('id')) {
+      $id = $original->id();
+      $id_key = $duplicate->getEntityType()->getKey('id');
+      $duplicate->set($id_key, $id . '_' . $key);
+    }
+    return $duplicate;
+  }
+
+  /**
+   * Returns the expected JSON:API document for the entity.
+   *
+   * @see ::createEntity()
+   *
+   * @return array
+   *   A JSON:API response document.
+   */
+  abstract protected function getExpectedDocument();
+
+  /**
+   * Returns the JSON:API POST document.
+   *
+   * @see ::testPostIndividual()
+   *
+   * @return array
+   *   A JSON:API request document.
+   */
+  abstract protected function getPostDocument();
+
+  /**
+   * Returns the JSON:API PATCH document.
+   *
+   * By default, reuses ::getPostDocument(), which works fine for most entity
+   * types. A counter example: the 'comment' entity type.
+   *
+   * @see ::testPatchIndividual()
+   *
+   * @return array
+   *   A JSON:API request document.
+   */
+  protected function getPatchDocument() {
+    return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getPostDocument());
+  }
+
+  /**
+   * Returns the expected cacheability for an unauthorized response.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The expected cacheability.
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    return (new CacheableMetadata())
+      ->setCacheTags(['4xx-response', 'http_response'])
+      ->setCacheContexts(['url.site', 'user.permissions'])
+      ->addCacheContexts($this->entity->getEntityType()->isRevisionable()
+        ? ['url.query_args:resourceVersion']
+        : []
+      );
+  }
+
+  /**
+   * The expected cache tags for the GET/HEAD response of the test entity.
+   *
+   * @param array|null $sparse_fieldset
+   *   If a sparse fieldset is being requested, limit the expected cache tags
+   *   for this entity's fields to just these fields.
+   *
+   * @return string[]
+   *   A set of cache tags.
+   *
+   * @see ::testGetIndividual()
+   */
+  protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
+    $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.
+   *
+   * @param array|null $sparse_fieldset
+   *   If a sparse fieldset is being requested, limit the expected cache
+   *   contexts for this entity's fields to just these fields.
+   *
+   * @return string[]
+   *   A set of cache contexts.
+   *
+   * @see ::testGetIndividual()
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    $cache_contexts = [
+      // Cache contexts for JSON:API URL query parameters.
+      'url.query_args:fields',
+      'url.query_args:include',
+      // Drupal defaults.
+      'url.site',
+      'user.permissions',
+    ];
+    $entity_type = $this->entity->getEntityType();
+    return Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+  }
+
+  /**
+   * Computes the cacheability for a given entity collection.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   An account for which cacheability should be computed (cacheability is
+   *   dependent on access).
+   * @param \Drupal\Core\Entity\EntityInterface[] $collection
+   *   The entities for which cacheability should be computed.
+   * @param array $sparse_fieldset
+   *   (optional) If a sparse fieldset is being requested, limit the expected
+   *   cacheability for the collection entities' fields to just those in the
+   *   fieldset. NULL means all fields.
+   * @param bool $filtered
+   *   Whether the collection is filtered or not.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The expected cacheability for the given entity collection.
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    $cacheability = array_reduce($collection, function (CacheableMetadata $cacheability, EntityInterface $entity) use ($sparse_fieldset, $account) {
+      $access_result = static::entityAccess($entity, 'view', $account);
+      if (!$access_result->isAllowed()) {
+        $access_result = static::entityAccess($entity, 'view label', $account)->addCacheableDependency($access_result);
+      }
+      $cacheability->addCacheableDependency($access_result);
+      if ($access_result->isAllowed()) {
+        $cacheability->addCacheableDependency($entity);
+        if ($entity instanceof FieldableEntityInterface) {
+          foreach ($entity as $field_name => $field_item_list) {
+            /* @var \Drupal\Core\Field\FieldItemListInterface $field_item_list */
+            if (is_null($sparse_fieldset) || in_array($field_name, $sparse_fieldset)) {
+              $field_access = static::entityFieldAccess($entity, $field_name, 'view', $account);
+              $cacheability->addCacheableDependency($field_access);
+              if ($field_access->isAllowed()) {
+                foreach ($field_item_list as $field_item) {
+                  /* @var \Drupal\Core\Field\FieldItemInterface $field_item */
+                  foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property) {
+                    $cacheability->addCacheableDependency(CacheableMetadata::createFromObject($property));
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+      return $cacheability;
+    }, new CacheableMetadata());
+    $entity_type = reset($collection)->getEntityType();
+    $cacheability->addCacheTags(['http_response']);
+    $cacheability->addCacheTags($entity_type->getListCacheTags());
+    $cache_contexts = [
+      // 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',
+    ];
+    // If the entity type is revisionable, add a resource version cache context.
+    $cache_contexts = Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $cacheability->addCacheContexts($cache_contexts);
+    return $cacheability;
+  }
+
+  /**
+   * 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);
+
+  /**
+   * Sets up the necessary authorization for handling revisions.
+   *
+   * @param string $method
+   *   The HTTP method for which to set up authentication.
+   *
+   * @see ::testRevisions()
+   */
+  protected function setUpRevisionAuthorization($method) {
+    assert($method === 'GET', 'Only read operations on revisions are supported.');
+    $this->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) {
+    $permission = $this->entity->getEntityType()->getAdminPermission();
+    if ($permission !== FALSE) {
+      return "The '{$permission}' permission is required.";
+    }
+
+    return NULL;
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * Revokes permissions from the authenticated role.
+   *
+   * @param string[] $permissions
+   *   Permissions to revoke.
+   */
+  protected function revokePermissionsFromTestedRole(array $permissions) {
+    $role = Role::load(RoleInterface::AUTHENTICATED_ID);
+    foreach ($permissions as $permission) {
+      $role->revokePermission($permission);
+    }
+    $role->trustData()->save();
+  }
+
+  /**
+   * Asserts that a resource response has the given status code and body.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param array|null|false $expected_document
+   *   The expected document or NULL if there should not be a 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_document, 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(), var_export(Json::decode((string) $response->getBody()), TRUE));
+    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_document !== FALSE) {
+        $response_document = Json::decode((string) $response->getBody());
+        if ($expected_document === NULL) {
+          $this->assertNull($response_document);
+        }
+        else {
+          $this->assertSameDocument($expected_document, $response_document);
+        }
+      }
+    }
+
+    // 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)) {
+      $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
+      $this->assertSame($optimized_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 an expected document matches the response body.
+   *
+   * @param array $expected_document
+   *   The expected JSON:API document.
+   * @param array $actual_document
+   *   The actual response document to assert.
+   */
+  protected function assertSameDocument(array $expected_document, array $actual_document) {
+    static::recursiveKsort($expected_document);
+    static::recursiveKsort($actual_document);
+
+    if (!empty($expected_document['included'])) {
+      static::sortResourceCollection($expected_document['included']);
+      static::sortResourceCollection($actual_document['included']);
+    }
+
+    if (isset($actual_document['meta']['omitted']) && isset($expected_document['meta']['omitted'])) {
+      $actual_omitted =& $actual_document['meta']['omitted'];
+      $expected_omitted =& $expected_document['meta']['omitted'];
+      static::sortOmittedLinks($actual_omitted);
+      static::sortOmittedLinks($expected_omitted);
+      static::resetOmittedLinkKeys($actual_omitted);
+      static::resetOmittedLinkKeys($expected_omitted);
+    }
+
+    $expected_keys = array_keys($expected_document);
+    $actual_keys = array_keys($actual_document);
+    $missing_member_names = array_diff($expected_keys, $actual_keys);
+    $extra_member_names = array_diff($actual_keys, $expected_keys);
+    if (!empty($missing_member_names) || !empty($extra_member_names)) {
+      $message_format = "The document members did not match the expected values. Missing: [ %s ]. Unexpected: [ %s ]";
+      $message = sprintf($message_format, implode(', ', $missing_member_names), implode(', ', $extra_member_names));
+      $this->assertSame($expected_document, $actual_document, $message);
+    }
+    foreach ($expected_document as $member_name => $expected_member) {
+      $actual_member = $actual_document[$member_name];
+      $this->assertSame($expected_member, $actual_member, "The '$member_name' member was not as expected.");
+    }
+  }
+
+  /**
+   * Asserts that a resource error response has the given message.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string $expected_message
+   *   The expected error message.
+   * @param \Drupal\Core\Url|null $via_link
+   *   The source URL for the errors of the response. NULL if the error occurs
+   *   for example during entity creation.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The error response to assert.
+   * @param string|false $pointer
+   *   The expected JSON Pointer to the associated entity in the request
+   *   document. See http://jsonapi.org/format/#error-objects.
+   * @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_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) {
+    assert(is_null($via_link) || $via_link instanceof Url);
+    $expected_error = [];
+    if (!empty(Response::$statusTexts[$expected_status_code])) {
+      $expected_error['title'] = Response::$statusTexts[$expected_status_code];
+    }
+    $expected_error['status'] = $expected_status_code;
+    $expected_error['detail'] = $expected_message;
+    if ($via_link) {
+      $expected_error['links']['via']['href'] = $via_link->setAbsolute()->toString();
+    }
+    if ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) {
+      $expected_error['links']['info']['href'] = $info_url;
+    }
+    if ($pointer !== FALSE) {
+      $expected_error['source']['pointer'] = $pointer;
+    }
+
+    $expected_document = [
+      'jsonapi' => static::$jsonApiMember,
+      'errors' => [
+        0 => $expected_error,
+      ],
+    ];
+    $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);
+  }
+
+  /**
+   * Makes the JSON:API document violate the spec by omitting the resource type.
+   *
+   * @param array $document
+   *   A JSON:API document.
+   *
+   * @return array
+   *   The same JSON:API document, without its resource type.
+   */
+  protected function removeResourceTypeFromDocument(array $document) {
+    unset($document['data']['type']);
+    return $document;
+  }
+
+  /**
+   * Makes the given JSON:API document invalid.
+   *
+   * @param array $document
+   *   A JSON:API document.
+   * @param string $entity_key
+   *   The entity key whose normalization to make invalid.
+   *
+   * @return array
+   *   The updated JSON:API document, now invalid.
+   */
+  protected function makeNormalizationInvalid(array $document, $entity_key) {
+    $entity_type = $this->entity->getEntityType();
+    switch ($entity_key) {
+      case 'label':
+        // Add a second label to this entity to make it invalid.
+        $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
+        $document['data']['attributes'][$label_field] = [
+          0 => $document['data']['attributes'][$label_field],
+          1 => 'Second Title',
+        ];
+        break;
+
+      case 'id':
+        $document['data']['attributes'][$entity_type->getKey('id')] = $this->anotherEntity->id();
+        break;
+
+      case 'uuid':
+        $document['data']['id'] = $this->anotherEntity->uuid();
+        break;
+    }
+
+    return $document;
+  }
+
+  /**
+   * 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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // DX: 403 when unauthorized, or 200 if the 'view label' operation is
+    // supported by the entity type.
+    $response = $this->request('GET', $url, $request_options);
+    if (!static::$anonymousUsersCanViewLabels) {
+      $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+      $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
+      $message = trim("The current user is not allowed to GET the selected resource. $reason");
+      $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS');
+      $this->assertArrayNotHasKey('Link', $response->getHeaders());
+    }
+    else {
+      $expected_document = $this->getExpectedDocument();
+      $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+      $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]);
+      unset($expected_document['data']['relationships']);
+      // MISS or UNCACHEABLE depends on data. It must not be HIT.
+      $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE' : 'MISS';
+      $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), FALSE, $dynamic_cache_label_only);
+    }
+
+    $this->setUpAuthorization('GET');
+
+    // Set body despite that being nonsensical: should be ignored.
+    $request_options[RequestOptions::BODY] = Json::encode($this->getExpectedDocument());
+
+    // 400 for GET request with reserved custom query parameter.
+    $url_reserved_custom_query_parameter = clone $url;
+    $url_reserved_custom_query_parameter = $url_reserved_custom_query_parameter->setOption('query', ['foo' => 'bar']);
+    $response = $this->request('GET', $url_reserved_custom_query_parameter, $request_options);
+    $expected_document = [
+      'jsonapi' => static::$jsonApiMember,
+      'errors' => [
+        [
+          'title' => 'Bad Request',
+          'status' => 400,
+          'detail' => "The following query parameters violate the JSON:API spec: 'foo'.",
+          'links' => [
+            'info' => ['href' => 'http://jsonapi.org/format/#query-parameters'],
+            'via' => ['href' => $url_reserved_custom_query_parameter->toString()],
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'MISS');
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    // MISS or UNCACHEABLE depends on data. It must not be HIT.
+    $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache);
+    $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, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
+    // 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->assertSame($dynamic_cache !== 'UNCACHEABLE' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE', $found_cached_200_response);
+    $this->assertTrue($other_cached_responses_are_4xx);
+
+    // Not only assert the normalization, also assert deserialization of the
+    // response results in the expected object.
+    $unserialized = $this->serializer->deserialize((string) $response->getBody(), JsonApiDocumentTopLevel::class, 'api_json', [
+      'target_entity' => static::$entityTypeId,
+      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+    ]);
+    $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);
+
+    // Feature: Sparse fieldsets.
+    $this->doTestSparseFieldSets($url, $request_options);
+    // Feature: Included.
+    $this->doTestIncluded($url, $request_options);
+
+    // DX: 404 when GETting non-existing entity.
+    $random_uuid = \Drupal::service('uuid')->generate();
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $random_uuid]);
+    $response = $this->request('GET', $url, $request_options);
+    $message_url = clone $url;
+    $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
+
+    // DX: when Accept request header is missing, still 404, same response.
+    unset($request_options[RequestOptions::HEADERS]['Accept']);
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE');
+  }
+
+  /**
+   * Tests GETting a collection of resources.
+   */
+  public function testCollection() {
+    $entity_collection = $this->getEntityCollection();
+    assert(count($entity_collection) > 1, 'A collection must have more that one entity in it.');
+
+    $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // This asserts that collections will work without a sort, added by default
+    // below, without actually asserting the content of the response.
+    $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $response = $this->request('HEAD', $collection_url, $request_options);
+    // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS';
+    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    // Different databases have different sort orders, so a sort is required so
+    // test expectations do not need to vary per database.
+    $default_sort = ['sort' => 'drupal_internal__' . $this->entity->getEntityType()->getKey('id')];
+    $collection_url->setOption('query', $default_sort);
+
+    // 200 for collections, even when all entities are inaccessible. Access is
+    // on a per-entity basis, which is handled by
+    // self::getExpectedCollectionResponse().
+    $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $expected_document = $expected_response->getResponseData();
+    $response = $this->request('GET', $collection_url, $request_options);
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    $this->setUpAuthorization('GET');
+
+    // 200 for well-formed HEAD request.
+    $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $response = $this->request('HEAD', $collection_url, $request_options);
+    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    // 200 for well-formed GET request.
+    $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $expected_document = $expected_response->getResponseData();
+    $response = $this->request('GET', $collection_url, $request_options);
+    // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE.
+    $dynamic_cache = $dynamic_cache === 'UNCACHEABLE' ? 'UNCACHEABLE' : 'HIT';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // 403 for filtering on an unauthorized field on the base resource type.
+      $unauthorized_filter_url = clone $collection_url;
+      $unauthorized_filter_url->setOption('query', [
+        'filter' => [
+          'related_author_id' => [
+            'operator' => '<>',
+            'path' => 'field_jsonapi_test_entity_ref.status',
+            'value' => 'doesnt@matter.com',
+          ],
+        ],
+      ]);
+      $response = $this->request('GET', $unauthorized_filter_url, $request_options);
+      $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.";
+      $expected_cache_tags = ['4xx-response', 'http_response'];
+      $expected_cache_contexts = [
+        'url.query_args:filter',
+        'url.query_args:sort',
+        'url.site',
+        'user.permissions',
+      ];
+      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+
+      $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']);
+
+      // 403 for filtering on an unauthorized field on a related resource type.
+      $response = $this->request('GET', $unauthorized_filter_url, $request_options);
+      $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`.";
+      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    }
+
+    // Remove an entity from the collection, then filter it out.
+    $filtered_entity_collection = $entity_collection;
+    $removed = array_shift($filtered_entity_collection);
+    $filtered_collection_url = clone $collection_url;
+    $entity_collection_filter = [
+      'filter' => [
+        'ids' => [
+          'condition' => [
+            'operator' => '<>',
+            'path' => 'id',
+            'value' => $removed->uuid(),
+          ],
+        ],
+      ],
+    ];
+    $filtered_collection_url->setOption('query', $entity_collection_filter + $default_sort);
+    $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_url->toString(), $request_options, NULL, TRUE);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $expected_document = $expected_response->getResponseData();
+    $response = $this->request('GET', $filtered_collection_url, $request_options);
+    // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    // Filtered collection with includes.
+    $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) {
+      return array_unique(array_merge($relationship_field_names, $this->getRelationshipFieldNames($entity)));
+    }, []);
+    $include = ['include' => implode(',', $relationship_field_names)];
+    $filtered_collection_include_url = clone $collection_url;
+    $filtered_collection_include_url->setOption('query', $entity_collection_filter + $include + $default_sort);
+    $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
+    $expected_document = $expected_response->getResponseData();
+    $response = $this->request('GET', $filtered_collection_include_url, $request_options);
+    // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+
+    // If the response should vary by a user's authorizations, grant permissions
+    // for the included resources and execute another request.
+    $permission_related_cache_contexts = [
+      'user',
+      'user.permissions',
+      'user.roles',
+    ];
+    if (!empty($relationship_field_names) && !empty(array_intersect($expected_cacheability->getCacheContexts(), $permission_related_cache_contexts))) {
+      $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($relationship_field_names));
+      $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
+      $this->grantPermissionsToTestedRole($flattened_permissions);
+      $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE);
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $expected_document = $expected_response->getResponseData();
+      $response = $this->request('GET', $filtered_collection_include_url, $request_options);
+      $requires_include_only_permissions = !empty($flattened_permissions);
+      $uncacheable = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts()));
+      $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE';
+      $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    }
+
+    // Sorted collection with includes.
+    $sorted_entity_collection = $entity_collection;
+    uasort($sorted_entity_collection, function (EntityInterface $a, EntityInterface $b) {
+      // Sort by ID in reverse order.
+      return strcmp($b->uuid(), $a->uuid());
+    });
+    $sorted_collection_include_url = clone $collection_url;
+    $sorted_collection_include_url->setOption('query', $include + ['sort' => "-id"]);
+    $expected_response = $this->getExpectedCollectionResponse($sorted_entity_collection, $sorted_collection_include_url->toString(), $request_options, $relationship_field_names);
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
+    $expected_document = $expected_response->getResponseData();
+    $response = $this->request('GET', $sorted_collection_include_url, $request_options);
+    // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+  }
+
+  /**
+   * Returns a JSON:API collection document for the expected entities.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface[] $collection
+   *   The entities for the collection.
+   * @param string $self_link
+   *   The self link for the collection response document.
+   * @param array $request_options
+   *   Request options to apply.
+   * @param array|null $included_paths
+   *   (optional) Any include paths that should be appended to the expected
+   *   response.
+   * @param bool $filtered
+   *   Whether the collection is filtered or not.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   A ResourceResponse for the expected entity collection.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getExpectedCollectionResponse(array $collection, $self_link, array $request_options, array $included_paths = NULL, $filtered = FALSE) {
+    $resource_identifiers = array_map([static::class, 'toResourceIdentifier'], $collection);
+    $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
+    $merged_response = static::toCollectionResourceResponse($individual_responses, $self_link, TRUE);
+
+    $merged_document = $merged_response->getResponseData();
+    if (!isset($merged_document['data'])) {
+      $merged_document['data'] = [];
+    }
+
+    $cacheability = static::getExpectedCollectionCacheability($this->account, $collection, NULL, $filtered);
+    $cacheability->setCacheMaxAge($merged_response->getCacheableMetadata()->getCacheMaxAge());
+
+    $collection_response = ResourceResponse::create($merged_document);
+    $collection_response->addCacheableDependency($cacheability);
+
+    if (is_null($included_paths)) {
+      return $collection_response;
+    }
+
+    $related_responses = array_reduce($collection, function ($related_responses, EntityInterface $entity) use ($included_paths, $request_options, $self_link) {
+      if (!$entity->access('view', $this->account) && !$entity->access('view label', $this->account)) {
+        return $related_responses;
+      }
+      $expected_related_responses = $this->getExpectedRelatedResponses($included_paths, $request_options, $entity);
+      if (empty($related_responses)) {
+        return $expected_related_responses;
+      }
+      foreach ($included_paths as $included_path) {
+        $both_responses = [$related_responses[$included_path], $expected_related_responses[$included_path]];
+        $related_responses[$included_path] = static::toCollectionResourceResponse($both_responses, $self_link, TRUE);
+      }
+      return $related_responses;
+    }, []);
+
+    return static::decorateExpectedResponseForIncludedFields($collection_response, $related_responses);
+  }
+
+  /**
+   * Tests GETing related resource of an individual resource.
+   *
+   * Expected responses are built by making requests to 'relationship' routes.
+   * Using the fetched resource identifiers, if any, all targeted resources are
+   * fetched individually. These individual responses are then 'merged' into a
+   * single expected ResourceResponse. This is repeated for every relationship
+   * field of the resource type under test.
+   */
+  public function testRelated() {
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $this->doTestRelated($request_options);
+    $this->setUpAuthorization('GET');
+    $this->doTestRelated($request_options);
+  }
+
+  /**
+   * Tests CRUD of individual resource relationship data.
+   *
+   * Unlike the "related" routes, relationship routes only return information
+   * about the "relationship" itself, not the targeted resources. For JSON:API
+   * with Drupal, relationship routes are like looking at an entity reference
+   * field without loading the entities. It only reveals the type of the
+   * targeted resource and the target resource IDs. These type+ID combos are
+   * referred to as "resource identifiers."
+   */
+  public function testRelationships() {
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->markTestSkipped('Configuration entities cannot have relationships.');
+    }
+
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Test GET.
+    $this->doTestRelationshipGet($request_options);
+    $this->setUpAuthorization('GET');
+    $this->doTestRelationshipGet($request_options);
+
+    // Test POST.
+    $this->doTestRelationshipMutation($request_options);
+    // Grant entity-level edit access.
+    $this->setUpAuthorization('PATCH');
+    $this->doTestRelationshipMutation($request_options);
+    // Field edit access is still forbidden, grant it.
+    $this->grantPermissionsToTestedRole([
+      'field_jsonapi_test_entity_ref view access',
+      'field_jsonapi_test_entity_ref edit access',
+      'field_jsonapi_test_entity_ref update access',
+    ]);
+    $this->doTestRelationshipMutation($request_options);
+  }
+
+  /**
+   * Performs one round of related route testing.
+   *
+   * By putting this behavior in its own method, authorization and other
+   * variations can be done in the calling method around assertions. For
+   * example, it can be run once with an authorized user and again without one.
+   *
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function doTestRelated(array $request_options) {
+    $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
+    // If there are no relationship fields, we can't test related routes.
+    if (empty($relationship_field_names)) {
+      return;
+    }
+    // Builds an array of expected responses, keyed by relationship field name.
+    $expected_relationship_responses = $this->getExpectedRelatedResponses($relationship_field_names, $request_options);
+    // Fetches actual responses as an array keyed by relationship field name.
+    $related_responses = $this->getRelatedResponses($relationship_field_names, $request_options);
+    foreach ($relationship_field_names as $relationship_field_name) {
+      /* @var \Drupal\jsonapi\ResourceResponse $expected_resource_response */
+      $expected_resource_response = $expected_relationship_responses[$relationship_field_name];
+      /* @var \Psr\Http\Message\ResponseInterface $actual_response */
+      $actual_response = $related_responses[$relationship_field_name];
+      // Dynamic Page Cache miss because cache should vary based on the
+      // 'include' query param.
+      $expected_cacheability = $expected_resource_response->getCacheableMetadata();
+      $this->assertResourceResponse(
+        $expected_resource_response->getStatusCode(),
+        $expected_resource_response->getResponseData(),
+        $actual_response,
+        $expected_cacheability->getCacheTags(),
+        $expected_cacheability->getCacheContexts(),
+        FALSE,
+        $actual_response->getStatusCode() === 200
+          ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS')
+          : FALSE
+      );
+    }
+  }
+
+  /**
+   * Performs one round of relationship route testing.
+   *
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   * @see ::testRelationships
+   */
+  protected function doTestRelationshipGet(array $request_options) {
+    $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
+    // If there are no relationship fields, we can't test relationship routes.
+    if (empty($relationship_field_names)) {
+      return;
+    }
+
+    // Test GET.
+    $related_responses = $this->getRelationshipResponses($relationship_field_names, $request_options);
+    foreach ($relationship_field_names as $relationship_field_name) {
+      $expected_resource_response = $this->getExpectedGetRelationshipResponse($relationship_field_name);
+      $expected_document = $expected_resource_response->getResponseData();
+      $expected_cacheability = $expected_resource_response->getCacheableMetadata();
+      $actual_response = $related_responses[$relationship_field_name];
+      $this->assertResourceResponse(
+        $expected_resource_response->getStatusCode(),
+        $expected_document,
+        $actual_response,
+        $expected_cacheability->getCacheTags(),
+        $expected_cacheability->getCacheContexts(),
+        FALSE,
+        $expected_resource_response->isSuccessful() ? 'MISS' : FALSE
+      );
+    }
+  }
+
+  /**
+   * Performs one round of relationship POST, PATCH and DELETE route testing.
+   *
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   * @see ::testRelationships
+   */
+  protected function doTestRelationshipMutation(array $request_options) {
+    /* @var \Drupal\Core\Entity\FieldableEntityInterface $resource */
+    $resource = $this->createAnotherEntity('dupe');
+    $resource->set('field_jsonapi_test_entity_ref', NULL);
+    $violations = $resource->validate();
+    assert($violations->count() === 0, (string) $violations);
+    $resource->save();
+    $target_resource = $this->createUser();
+    $violations = $target_resource->validate();
+    assert($violations->count() === 0, (string) $violations);
+    $target_resource->save();
+    $target_identifier = static::toResourceIdentifier($target_resource);
+    $resource_identifier = static::toResourceIdentifier($resource);
+    $relationship_field_name = 'field_jsonapi_test_entity_ref';
+    /* @var \Drupal\Core\Access\AccessResultReasonInterface $update_access */
+    $update_access = static::entityAccess($resource, 'update', $this->account)
+      ->andIf(static::entityFieldAccess($resource, $relationship_field_name, 'edit', $this->account));
+    $url = Url::fromRoute(sprintf("jsonapi.{$resource_identifier['type']}.{$relationship_field_name}.relationship.patch"), [
+      'entity' => $resource->uuid(),
+    ]);
+
+    // Test POST: missing content-type.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+
+    // Set the JSON:API media type header for all subsequent requests.
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    if ($update_access->isAllowed()) {
+      // Test POST: empty body.
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
+      // Test PATCH: empty body.
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
+
+      // Test POST: empty data.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => []]);
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+      // Test PATCH: empty data.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => []]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+
+      // Test POST: data as resource identifier, not array of identifiers.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]);
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
+      // Test PATCH: data as resource identifier, not array of identifiers.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
+
+      // Test POST: missing the 'type' field.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]);
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
+      // Test PATCH: missing the 'type' field.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE);
+
+      // If the base resource type is the same as that of the target's (as it
+      // will be for `user--user`), then the validity error will not be
+      // triggered, needlessly failing this assertion.
+      if (static::$resourceTypeName !== $target_identifier['type']) {
+        // Test POST: invalid target.
+        $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]);
+        $response = $this->request('POST', $url, $request_options);
+        $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);
+        // Test PATCH: invalid target.
+        $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]);
+        $response = $this->request('POST', $url, $request_options);
+        $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);
+      }
+
+      // Test POST: duplicate targets, no arity.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]);
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
+
+      // Test PATCH: duplicate targets, no arity.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE);
+
+      // Test POST: success.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+
+      // Test POST: success, relationship already exists, no arity.
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+
+      // Test POST: success, relationship already exists, new arity.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier + ['meta' => ['arity' => 1]]]]);
+      $response = $this->request('POST', $url, $request_options);
+      $resource->set($relationship_field_name, [$target_resource, $target_resource]);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $expected_document['data'][0] += ['meta' => ['arity' => 0]];
+      $expected_document['data'][1] += ['meta' => ['arity' => 1]];
+      $this->assertResourceResponse(200, $expected_document, $response);
+
+      // Test PATCH: success, new value is the same as given value.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 0]],
+          $target_identifier + ['meta' => ['arity' => 1]],
+        ],
+      ]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+
+      // Test POST: success, relationship already exists, new arity.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 2]],
+        ],
+      ]);
+      $response = $this->request('POST', $url, $request_options);
+      $resource->set($relationship_field_name, [
+        $target_resource,
+        $target_resource,
+        $target_resource,
+      ]);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $expected_document['data'][0] += ['meta' => ['arity' => 0]];
+      $expected_document['data'][1] += ['meta' => ['arity' => 1]];
+      $expected_document['data'][2] += ['meta' => ['arity' => 2]];
+      // 200 with response body because the request did not include the
+      // existing relationship resource identifier object.
+      $this->assertResourceResponse(200, $expected_document, $response);
+
+      // Test POST: success.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 0]],
+          $target_identifier + ['meta' => ['arity' => 1]],
+        ],
+      ]);
+      $response = $this->request('POST', $url, $request_options);
+      // 200 with response body because the request did not include the
+      // resource identifier with arity 2.
+      $this->assertResourceResponse(200, $expected_document, $response);
+
+      // Test PATCH: success.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 0]],
+          $target_identifier + ['meta' => ['arity' => 1]],
+          $target_identifier + ['meta' => ['arity' => 2]],
+        ],
+      ]);
+      $response = $this->request('PATCH', $url, $request_options);
+      // 204 no content. PATCH data matches existing data.
+      $this->assertResourceResponse(204, NULL, $response);
+
+      // Test DELETE: three existing relationships, two removed.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 0]],
+          $target_identifier + ['meta' => ['arity' => 2]],
+        ],
+      ]);
+      $response = $this->request('DELETE', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+      // Subsequent GET should return only one resource identifier, with no
+      // arity.
+      $resource->set($relationship_field_name, [$target_resource]);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
+
+      // Test DELETE: one existing relationship, removed.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
+      $response = $this->request('DELETE', $url, $request_options);
+      $resource->set($relationship_field_name, []);
+      $this->assertResourceResponse(204, NULL, $response);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
+
+      // Test DELETE: no existing relationships, no op, success.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
+      $response = $this->request('DELETE', $url, $request_options);
+      $this->assertResourceResponse(204, NULL, $response);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
+
+      // Test PATCH: success, new value is different than existing value.
+      $request_options[RequestOptions::BODY] = Json::encode([
+        'data' => [
+          $target_identifier + ['meta' => ['arity' => 2]],
+          $target_identifier + ['meta' => ['arity' => 3]],
+        ],
+      ]);
+      $response = $this->request('PATCH', $url, $request_options);
+      $resource->set($relationship_field_name, [$target_resource, $target_resource]);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $expected_document['data'][0] += ['meta' => ['arity' => 0]];
+      $expected_document['data'][1] += ['meta' => ['arity' => 1]];
+      // 200 with response body because arity values are computed; that means
+      // that the PATCH arity values 2 + 3 will become 0 + 1 if there are not
+      // already resource identifiers with those arity values.
+      $this->assertResourceResponse(200, $expected_document, $response);
+
+      // Test DELETE: two existing relationships, both removed because no arity
+      // was specified.
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
+      $response = $this->request('DELETE', $url, $request_options);
+      $resource->set($relationship_field_name, []);
+      $this->assertResourceResponse(204, NULL, $response);
+      $resource->set($relationship_field_name, []);
+      $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource);
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody()));
+    }
+    else {
+      $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]);
+      $response = $this->request('POST', $url, $request_options);
+      $message = 'The current user is not allowed to edit this relationship.';
+      $message .= ($reason = $update_access->getReason()) ? ' ' . $reason : '';
+      $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
+      $response = $this->request('DELETE', $url, $request_options);
+      $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE);
+    }
+
+    // Remove the test entities that were created.
+    $resource->delete();
+    $target_resource->delete();
+  }
+
+  /**
+   * Gets an expected ResourceResponse for the given relationship.
+   *
+   * @param string $relationship_field_name
+   *   The relationship for which to get an expected response.
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get expected relationship response.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The expected ResourceResponse.
+   */
+  protected function getExpectedGetRelationshipResponse($relationship_field_name, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType->getInternalName($relationship_field_name), 'view', $this->account));
+    if (!$access->isAllowed()) {
+      $via_link = Url::fromRoute(
+        sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, $relationship_field_name),
+        ['entity' => $entity->uuid()]
+      );
+      return static::getAccessDeniedResponse($this->entity, $access, $via_link, $relationship_field_name, 'The current user is not allowed to view this relationship.', FALSE);
+    }
+    $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $entity);
+    $expected_cacheability = (new CacheableMetadata())
+      ->addCacheTags(['http_response'])
+      ->addCacheContexts([
+        'url.site',
+        'url.query_args:include',
+        'url.query_args:fields',
+      ])
+      ->addCacheableDependency($entity)
+      ->addCacheableDependency($access);
+    $status_code = isset($expected_document['errors'][0]['status']) ? $expected_document['errors'][0]['status'] : 200;
+    $resource_response = new ResourceResponse($expected_document, $status_code);
+    $resource_response->addCacheableDependency($expected_cacheability);
+    return $resource_response;
+  }
+
+  /**
+   * Gets an expected document for the given relationship.
+   *
+   * @param string $relationship_field_name
+   *   The relationship for which to get an expected response.
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get expected relationship document.
+   *
+   * @return array
+   *   The expected document array.
+   */
+  protected function getExpectedGetRelationshipDocument($relationship_field_name, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    $entity_type_id = $entity->getEntityTypeId();
+    $bundle = $entity->bundle();
+    $id = $entity->uuid();
+    $self_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/relationships/$relationship_field_name")->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $related_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/$relationship_field_name")->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
+    return [
+      'data' => $data,
+      'jsonapi' => static::$jsonApiMember,
+      'links' => [
+        'self' => ['href' => $self_link],
+        'related' => ['href' => $related_link],
+      ],
+    ];
+  }
+
+  /**
+   * Gets the expected document data for the given relationship.
+   *
+   * @param string $relationship_field_name
+   *   The relationship for which to get an expected response.
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get expected relationship data.
+   *
+   * @return mixed
+   *   The expected document data.
+   */
+  protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    $internal_field_name = $this->resourceType->getInternalName($relationship_field_name);
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+    $field = $entity->{$internal_field_name};
+    $is_multiple = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() !== 1;
+    if ($field->isEmpty()) {
+      return $is_multiple ? [] : NULL;
+    }
+    if (!$is_multiple) {
+      $target_entity = $field->entity;
+      return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity);
+    }
+    else {
+      return array_filter(array_map(function ($item) {
+        $target_entity = $item->entity;
+        return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity);
+      }, iterator_to_array($field)));
+    }
+  }
+
+  /**
+   * Builds an array of expected related ResourceResponses, keyed by field name.
+   *
+   * @param array $relationship_field_names
+   *   The relationship field names for which to build expected
+   *   ResourceResponses.
+   * @param array $request_options
+   *   Request options to apply.
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get expected related resources.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse[]
+   *   An array of expected ResourceResponses, keyed by their relationship field
+   *   name.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    return array_map(function ($relationship_field_name) use ($entity, $request_options) {
+      return $this->getExpectedRelatedResponse($relationship_field_name, $request_options, $entity);
+    }, array_combine($relationship_field_names, $relationship_field_names));
+  }
+
+  /**
+   * Builds an expected related ResourceResponse for the given field.
+   *
+   * @param string $relationship_field_name
+   *   The relationship field name for which to build an expected
+   *   ResourceResponse.
+   * @param array $request_options
+   *   Request options to apply.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to get expected related resources.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   An expected ResourceResponse.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getExpectedRelatedResponse($relationship_field_name, array $request_options, EntityInterface $entity) {
+    // Get the relationships responses which contain resource identifiers for
+    // every related resource.
+    /* @var \Drupal\jsonapi\ResourceResponse[] $relationship_responses */
+    $base_resource_identifier = static::toResourceIdentifier($entity);
+    $internal_name = $this->resourceType->getInternalName($relationship_field_name);
+    $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+    $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account));
+    if (!$access->isAllowed()) {
+      $detail = 'The current user is not allowed to view this relationship.';
+      if (!$entity->access('view') && $entity->access('view label') && $access instanceof AccessResultReasonInterface && empty($access->getReason())) {
+        $access->setReason("The user only has authorization for the 'view label' operation.");
+      }
+      $via_link = Url::fromRoute(
+        sprintf('jsonapi.%s.%s.related', $base_resource_identifier['type'], $relationship_field_name),
+        ['entity' => $base_resource_identifier['id']]
+      );
+      $related_response = static::getAccessDeniedResponse($entity, $access, $via_link, $relationship_field_name, $detail, FALSE);
+    }
+    else {
+      $self_link = static::getRelatedLink($base_resource_identifier, $relationship_field_name);
+      $relationship_response = $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity);
+      $relationship_document = $relationship_response->getResponseData();
+      // The relationships may be empty, in which case we shouldn't attempt to
+      // fetch the individual identified resources.
+      if (empty($relationship_document['data'])) {
+        $cache_contexts = Cache::mergeContexts([
+          // Cache contexts for JSON:API URL query parameters.
+          'url.query_args:fields',
+          'url.query_args:include',
+          // Drupal defaults.
+          'url.site',
+        ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []);
+        $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']);
+        $related_response = isset($relationship_document['errors'])
+          ? $relationship_response
+          : (new ResourceResponse(static::getEmptyCollectionResponse(!is_null($relationship_document['data']), $self_link)->getResponseData()))->addCacheableDependency($cacheability);
+      }
+      else {
+        $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']);
+        $resource_identifiers = $is_to_one_relationship
+          ? [$relationship_document['data']]
+          : $relationship_document['data'];
+        // Remove any relationships to 'virtual' resources.
+        $resource_identifiers = array_filter($resource_identifiers, function ($resource_identifier) {
+          return $resource_identifier['id'] !== 'virtual';
+        });
+        if (!empty($resource_identifiers)) {
+          $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
+          $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship);
+        }
+        else {
+          $related_response = static::getEmptyCollectionResponse(!$is_to_one_relationship, $self_link);
+        }
+      }
+      $related_response->addCacheableDependency($relationship_response->getCacheableMetadata());
+    }
+    return $related_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 = Json::encode($this->getPostDocument());
+    /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */
+    $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type'));
+    $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label'));
+    $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument()));
+    $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument()));
+    $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument()));
+
+    // 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.post', static::$resourceTypeName));
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // DX: 400 when no request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE);
+
+    $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, 'Resource object must include a "type".', $url, $response, FALSE);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('POST');
+    $this->assertResourceErrorResponse(403, (string) $reason, $url, $response);
+
+    $this->setUpAuthorization('POST');
+
+    // 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();
+    $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+    // DX: 403 when invalid entity: UUID field too long.
+    // @todo Fix this in https://www.drupal.org/project/drupal/issues/2149851.
+    if ($this->entity->getEntityType()->hasKey('uuid')) {
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(422, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $url, $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, "The current user is not allowed to POST the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
+
+    // DX: 422 when request document contains non-existent field.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+    $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"', $url, $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);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // If the entity is stored, perform extra checks.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $uuid = $this->entityStorage->load(static::$firstCreatedEntityId)->uuid();
+      // @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), ['entity' => $uuid])->setAbsolute(TRUE)->toString();
+      /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
+      $this->assertSame([$location], $response->getHeader('Location'));
+
+      // 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_document = $this->normalize($created_entity, $url);
+      $decoded_response_body = Json::decode((string) $response->getBody());
+      $this->assertSame($created_entity_document, $decoded_response_body);
+      // Assert that the entity was indeed created using the POSTed values.
+      foreach ($this->getPostDocument()['data']['attributes'] as $field_name => $field_normalization) {
+        // If the value is an array of properties, only verify that the sent
+        // properties are present, the server could be computing additional
+        // properties.
+        if (is_array($field_normalization)) {
+          $this->assertArraySubset($field_normalization, $created_entity_document['data']['attributes'][$field_name]);
+        }
+        else {
+          $this->assertSame($field_normalization, $created_entity_document['data']['attributes'][$field_name]);
+        }
+      }
+      if (isset($this->getPostDocument()['data']['relationships'])) {
+        foreach ($this->getPostDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
+          // POSTing relationships: 'data' is required, 'links' is optional.
+          static::recursiveKsort($relationship_field_normalization);
+          static::recursiveKsort($created_entity_document['data']['relationships'][$field_name]);
+          $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], ['links' => TRUE]));
+        }
+      }
+    }
+    else {
+      $this->assertFalse($response->hasHeader('Location'));
+    }
+
+    // 201 for well-formed request that creates another entity.
+    // If the entity is stored, delete the first created entity (in case there
+    // is a uniqueness constraint).
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
+    }
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+    if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
+      $uuid = $this->entityStorage->load(static::$secondCreatedEntityId)->uuid();
+      // @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), ['entity' => $uuid])->setAbsolute(TRUE)->toString();
+      /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
+      $this->assertSame([$location], $response->getHeader('Location'));
+
+      // 500 when creating an entity with a duplicate UUID.
+      $doc = $this->getModifiedEntityForPostTesting();
+      $doc['data']['id'] = $uuid;
+      $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]];
+      $request_options[RequestOptions::BODY] = Json::encode($doc);
+
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceErrorResponse(409, 'Conflict: Entity already exists.', $url, $response, FALSE);
+
+      // 201 when successfully creating an entity with a new UUID.
+      $doc = $this->getModifiedEntityForPostTesting();
+      $new_uuid = \Drupal::service('uuid')->generate();
+      $doc['data']['id'] = $new_uuid;
+      $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]];
+      $request_options[RequestOptions::BODY] = Json::encode($doc);
+
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResourceResponse(201, FALSE, $response);
+      $entities = $this->entityStorage->loadByProperties([$this->uuidKey => $new_uuid]);
+      $new_entity = reset($entities);
+      $this->assertNotNull($new_entity);
+      $new_entity->delete();
+    }
+    else {
+      $this->assertFalse($response->hasHeader('Location'));
+    }
+  }
+
+  /**
+   * 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;
+    }
+
+    // Patch testing requires that another entity of the same type exists.
+    $this->anotherEntity = $this->createAnotherEntity('dupe');
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body = Json::encode($this->getPatchDocument());
+    /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */
+    $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label'));
+    $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPatchDocument()));
+    // The 'field_rest_test' field does not allow 'view' access, so does not end
+    // up in the JSON:API document. Even when we explicitly add it to the JSON
+    // API document that we send in a PATCH request, it is considered invalid.
+    $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()]]], $this->getPatchDocument()));
+    $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPatchDocument()));
+    // It is invalid to PATCH a relationship field under the attributes member.
+    if ($this->entity instanceof FieldableEntityInterface && $this->entity->hasField('field_jsonapi_test_entity_ref')) {
+      $parseable_invalid_request_body_5 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_jsonapi_test_entity_ref' => ['target_id' => $this->randomString()]]]], $this->getPostDocument()));
+    }
+
+    // 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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertsame(415, $response->getStatusCode());
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // DX: 400 when no request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE);
+
+    $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', $url, $response, FALSE);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH');
+    $this->assertResourceErrorResponse(403, (string) $reason, $url, $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, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field);
+
+    $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, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
+
+    // DX: 403 when entity trying to update an entity's ID field.
+    $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'id'));
+    $response = $this->request('PATCH', $url, $request_options);
+    $id_field_name = $this->entity->getEntityType()->getKey('id');
+    $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");
+
+    if ($this->entity->getEntityType()->hasKey('uuid')) {
+      // DX: 400 when entity trying to update an entity's UUID field.
+      $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'uuid'));
+      $response = $this->request('PATCH', $url, $request_options);
+      $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);
+    }
+
+    $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, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test');
+
+    // 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.
+    foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
+      $request_options[RequestOptions::BODY] = Json::encode($this->normalize($modified_entity, $url));
+      $response = $this->request('PATCH', $url, $request_options);
+      $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);
+      $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
+    }
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4;
+
+    // DX: 422 when request document contains non-existent field.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE);
+
+    // DX: 422 when updating a relationship field under attributes.
+    if (isset($parseable_invalid_request_body_5)) {
+      $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_5;
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(422, "The following relationship fields were provided as attributes: [ field_jsonapi_test_entity_ref ]", $url, $response, FALSE);
+    }
+
+    // 200 for well-formed PATCH request that sends all fields (even including
+    // read-only ones, but with unchanged values).
+    $valid_request_body = NestedArray::mergeDeep($this->normalize($this->entity, $url), $this->getPatchDocument());
+    $request_options[RequestOptions::BODY] = Json::encode($valid_request_body);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+    $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->assertSame(415, $response->getStatusCode());
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // 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_document = $this->normalize($updated_entity, $url);
+    $this->assertSame($updated_entity_document, Json::decode((string) $response->getBody()));
+    // Assert that the entity was indeed created using the PATCHed values.
+    foreach ($this->getPatchDocument()['data']['attributes'] as $field_name => $field_normalization) {
+      // If the value is an array of properties, only verify that the sent
+      // properties are present, the server could be computing additional
+      // properties.
+      if (is_array($field_normalization)) {
+        $this->assertArraySubset($field_normalization, $updated_entity_document['data']['attributes'][$field_name]);
+      }
+      else {
+        $this->assertSame($field_normalization, $updated_entity_document['data']['attributes'][$field_name]);
+      }
+    }
+    if (isset($this->getPatchDocument()['data']['relationships'])) {
+      foreach ($this->getPatchDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) {
+        // POSTing relationships: 'data' is required, 'links' is optional.
+        static::recursiveKsort($relationship_field_normalization);
+        static::recursiveKsort($updated_entity_document['data']['relationships'][$field_name]);
+        $this->assertSame($relationship_field_normalization, array_diff_key($updated_entity_document['data']['relationships'][$field_name], ['links' => 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.
+    $doc_multi_value_tests = $this->getPatchDocument();
+    $doc_multi_value_tests['data']['attributes']['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
+    $doc_remove_item = $doc_multi_value_tests;
+    unset($doc_remove_item['data']['attributes']['field_rest_test_multivalue'][0]);
+    $request_options[RequestOptions::BODY] = Json::encode($doc_remove_item, 'api_json');
+    $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.
+    $doc_add_items = $doc_multi_value_tests;
+    $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => 'Three'];
+    $request_options[RequestOptions::BODY] = Json::encode($doc_add_items);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $expected_document = [
+      0 => ['value' => 'One'],
+      1 => ['value' => 'Two'],
+      2 => ['value' => 'Three'],
+    ];
+    $this->assertSame($expected_document, $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
+  }
+
+  /**
+   * 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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('DELETE', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE');
+    $this->assertResourceErrorResponse(403, (string) $reason, $url, $response, FALSE);
+
+    $this->setUpAuthorization('DELETE');
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertResourceResponse(204, NULL, $response);
+
+    // DX: 404 when non-existent.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+  }
+
+  /**
+   * Recursively sorts an array by key.
+   *
+   * @param array $array
+   *   An array to sort.
+   */
+  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.
+   *
+   * @return array
+   *   Guzzle request options to use for authentication.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getAuthenticationRequestOptions() {
+    return [
+      'headers' => [
+        'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+      ],
+    ];
+  }
+
+  /**
+   * Clones the given entity and modifies all PATCH-protected fields.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being tested and to modify.
+   *
+   * @return array
+   *   Contains two items:
+   *   1. The modified entity object.
+   *   2. The original field values, keyed by field name.
+   *
+   * @internal
+   */
+  protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
+    $modified_entity = clone $entity;
+    $original_values = [];
+    foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
+      $field = $modified_entity->get($field_name);
+      $original_values[$field_name] = $field->getValue();
+      switch ($field->getItemDefinition()->getClass()) {
+        case BooleanItem::class:
+          // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
+          // chance of not picking a different value.
+          $field->value = ((int) $field->value) === 1 ? '0' : '1';
+          break;
+
+        case PathItem::class:
+          // PathItem::generateSampleValue() doesn't set a PID, which causes
+          // PathItem::postSave() to fail. Keep the PID (and other properties),
+          // just modify the alias.
+          $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
+          break;
+
+        default:
+          $original_field = clone $field;
+          while ($field->equals($original_field)) {
+            $field->generateSampleItems();
+          }
+          break;
+      }
+    }
+
+    return [$modified_entity, $original_values];
+  }
+
+  /**
+   * Gets the normalized POST entity with random values for its unique fields.
+   *
+   * @see ::testPostIndividual
+   * @see ::getPostDocument
+   *
+   * @return array
+   *   An array structure as returned by ::getNormalizedPostEntity().
+   */
+  protected function getModifiedEntityForPostTesting() {
+    $document = $this->getPostDocument();
+
+    // Ensure that all the unique fields of the entity type get a new random
+    // value.
+    foreach (static::$uniqueFieldNames as $field_name) {
+      $field_definition = $this->entity->getFieldDefinition($field_name);
+      $field_type_class = $field_definition->getItemDefinition()->getClass();
+      $document['data']['attributes'][$field_name] = $field_type_class::generateSampleValue($field_definition);
+    }
+
+    return $document;
+  }
+
+  /**
+   * Tests sparse field sets.
+   *
+   * @param \Drupal\Core\Url $url
+   *   The base URL with which to test includes.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function doTestSparseFieldSets(Url $url, array $request_options) {
+    $field_sets = $this->getSparseFieldSets();
+    $expected_cacheability = new CacheableMetadata();
+    foreach ($field_sets as $type => $field_set) {
+      if ($type === 'all') {
+        assert($this->getExpectedCacheTags($field_set) === $this->getExpectedCacheTags());
+        assert($this->getExpectedCacheContexts($field_set) === $this->getExpectedCacheContexts());
+      }
+      $query = ['fields[' . static::$resourceTypeName . ']' => implode(',', $field_set)];
+      $expected_document = $this->getExpectedDocument();
+      $expected_cacheability->setCacheTags($this->getExpectedCacheTags($field_set));
+      $expected_cacheability->setCacheContexts($this->getExpectedCacheContexts($field_set));
+      // This tests sparse field sets on included entities.
+      if (strpos($type, 'nested') === 0) {
+        $this->grantPermissionsToTestedRole(['access user profiles']);
+        $query['fields[user--user]'] = implode(',', $field_set);
+        $query['include'] = 'uid';
+        $owner = $this->entity->getOwner();
+        $owner_resource = static::toResourceIdentifier($owner);
+        foreach ($field_set as $field_name) {
+          $owner_resource['attributes'][$field_name] = $this->serializer->normalize($owner->get($field_name)[0]->get('value'), 'api_json');
+        }
+        $owner_resource['links']['self']['href'] = static::getResourceLink($owner_resource);
+        $expected_document['included'] = [$owner_resource];
+        $expected_cacheability->addCacheableDependency($owner);
+        $expected_cacheability->addCacheableDependency(static::entityAccess($owner, 'view', $this->account));
+      }
+      // Remove fields not in the sparse field set.
+      foreach (['attributes', 'relationships'] as $member) {
+        if (!empty($expected_document['data'][$member])) {
+          $remaining = array_intersect_key(
+            $expected_document['data'][$member],
+            array_flip($field_set)
+          );
+          if (empty($remaining)) {
+            unset($expected_document['data'][$member]);
+          }
+          else {
+            $expected_document['data'][$member] = $remaining;
+          }
+        }
+      }
+      $url->setOption('query', $query);
+      // 'self' link should include the 'fields' query param.
+      $expected_document['links']['self']['href'] = $url->setAbsolute()->toString();
+
+      $response = $this->request('GET', $url, $request_options);
+      // Dynamic Page Cache MISS because cache should vary based on the 'field'
+      // query param. (Or uncacheable if expensive cache context.)
+      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+      $this->assertResourceResponse(
+        200,
+        $expected_document,
+        $response,
+        $expected_cacheability->getCacheTags(),
+        $expected_cacheability->getCacheContexts(),
+        FALSE,
+        $dynamic_cache
+      );
+    }
+    // Test Dynamic Page Cache HIT for a query with the same field set (unless
+    // expensive cache context is present).
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
+  }
+
+  /**
+   * Tests included resources.
+   *
+   * @param \Drupal\Core\Url $url
+   *   The base URL with which to test includes.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function doTestIncluded(Url $url, array $request_options) {
+    $relationship_field_names = $this->getRelationshipFieldNames($this->entity);
+    // If there are no relationship fields, we can't include anything.
+    if (empty($relationship_field_names)) {
+      return;
+    }
+
+    $field_sets = [
+      'empty' => [],
+      'all' => $relationship_field_names,
+    ];
+    if (count($relationship_field_names) > 1) {
+      $about_half_the_fields = floor(count($relationship_field_names) / 2);
+      $field_sets['some'] = array_slice($relationship_field_names, $about_half_the_fields);
+
+      $nested_includes = $this->getNestedIncludePaths();
+      if (!empty($nested_includes) && !in_array($nested_includes, $field_sets)) {
+        $field_sets['nested'] = $nested_includes;
+      }
+    }
+
+    foreach ($field_sets as $type => $included_paths) {
+      $this->grantIncludedPermissions($included_paths);
+      $query = ['include' => implode(',', $included_paths)];
+      $url->setOption('query', $query);
+      $actual_response = $this->request('GET', $url, $request_options);
+      $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options);
+      $expected_document = $expected_response->getResponseData();
+      // Dynamic Page Cache miss because cache should vary based on the
+      // 'include' query param.
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      // MISS or UNCACHEABLE depends on data. It must not be HIT.
+      $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? 'UNCACHEABLE' : 'MISS';
+      $this->assertResourceResponse(
+        200,
+        $expected_document,
+        $actual_response,
+        $expected_cacheability->getCacheTags(),
+        $expected_cacheability->getCacheContexts(),
+        FALSE,
+        $dynamic_cache
+      );
+    }
+  }
+
+  /**
+   * Tests individual and collection revisions.
+   */
+  public function testRevisions() {
+    if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) {
+      return;
+    }
+    assert($this->entity instanceof RevisionableInterface);
+
+    // JSON:API will only support node and media revisions until Drupal core has
+    // a generic revision access API.
+    if (!in_array($this->entity->getEntityTypeId(), ['node', 'media'])) {
+      $this->setUpRevisionAuthorization('GET');
+      $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute();
+      $url->setOption('query', ['resourceVersion' => 'id:' . $this->entity->getRevisionId()]);
+      $request_options = [];
+      $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+      $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+      $response = $this->request('GET', $url, $request_options);
+      $detail = 'JSON:API does not yet support resource versioning for this resource type.';
+      $detail .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
+      $detail .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
+      $expected_cache_contexts = [
+        'url.path',
+        'url.query_args:resourceVersion',
+        'url.site',
+      ];
+      $this->assertResourceErrorResponse(501, $detail, $url, $response, FALSE, ['http_response'], $expected_cache_contexts);
+      return;
+    }
+
+    // Add a field to modify in order to test revisions.
+    FieldStorageConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_revisionable_number',
+      'type' => 'integer',
+    ])->setCardinality(1)->save();
+    FieldConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_revisionable_number',
+      'bundle' => $this->entity->bundle(),
+    ])->setLabel('Revisionable text field')->setTranslatable(FALSE)->save();
+
+    // Reload entity so that it has the new field.
+    $entity = $this->entityStorage->loadUnchanged($this->entity->id());
+
+    // Set up test data.
+    /* @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
+    $entity->set('field_revisionable_number', 42);
+    $entity->save();
+    $original_revision_id = (int) $entity->getRevisionId();
+
+    $entity->set('field_revisionable_number', 99);
+    $entity->setNewRevision();
+    $entity->save();
+    $latest_revision_id = (int) $entity->getRevisionId();
+
+    // @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), ['entity' => $this->entity->uuid()])->setAbsolute();
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute();
+    $relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
+    $related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute();
+    $original_revision_id_url = clone $url;
+    $original_revision_id_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
+    $original_revision_id_relationship_url = clone $relationship_url;
+    $original_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
+    $original_revision_id_related_url = clone $related_url;
+    $original_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]);
+    $latest_revision_id_url = clone $url;
+    $latest_revision_id_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
+    $latest_revision_id_relationship_url = clone $relationship_url;
+    $latest_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
+    $latest_revision_id_related_url = clone $related_url;
+    $latest_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]);
+    $rel_latest_version_url = clone $url;
+    $rel_latest_version_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
+    $rel_latest_version_relationship_url = clone $relationship_url;
+    $rel_latest_version_relationship_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
+    $rel_latest_version_related_url = clone $related_url;
+    $rel_latest_version_related_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
+    $rel_latest_version_collection_url = clone $collection_url;
+    $rel_latest_version_collection_url->setOption('query', ['resourceVersion' => 'rel:latest-version']);
+    $rel_working_copy_url = clone $url;
+    $rel_working_copy_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
+    $rel_working_copy_relationship_url = clone $relationship_url;
+    $rel_working_copy_relationship_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
+    $rel_working_copy_related_url = clone $related_url;
+    $rel_working_copy_related_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
+    $rel_working_copy_collection_url = clone $collection_url;
+    $rel_working_copy_collection_url->setOption('query', ['resourceVersion' => 'rel:working-copy']);
+    $rel_invalid_collection_url = clone $collection_url;
+    $rel_invalid_collection_url->setOption('query', ['resourceVersion' => 'rel:invalid']);
+    $revision_id_key = 'drupal_internal__' . $this->entity->getEntityType()->getKey('revision');
+    $published_key = $this->entity->getEntityType()->getKey('published');
+    $revision_translation_affected_key = $this->entity->getEntityType()->getKey('revision_translation_affected');
+
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Ensure 403 forbidden on typical GET.
+    $actual_response = $this->request('GET', $url, $request_options);
+    $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+    $result = $entity->access('view', $this->account, TRUE);
+    $detail = 'The current user is not allowed to GET the selected resource.';
+    if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
+      $detail .= ' ' . $reason;
+    }
+    $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+
+    // Ensure that targeting a revision does not bypass access.
+    $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
+    $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+    $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
+    if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) {
+      $detail .= ' ' . $reason;
+    }
+    $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+
+    $this->setUpRevisionAuthorization('GET');
+
+    // Ensure that the URL without a `resourceVersion` query parameter returns
+    // the default revision. This is always the latest revision when
+    // content_moderation is not installed.
+    $actual_response = $this->request('GET', $url, $request_options);
+    $expected_document = $this->getExpectedDocument();
+    // Resource objects always link to their specific revision by revision ID.
+    $expected_document['data']['attributes'][$revision_id_key] = $latest_revision_id;
+    $expected_document['data']['attributes']['field_revisionable_number'] = 99;
+    $expected_cache_tags = $this->getExpectedCacheTags();
+    $expected_cache_contexts = $this->getExpectedCacheContexts();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // Fetch the same revision using its revision ID.
+    $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
+    // The top-level document object's `self` link should always link to the
+    // request URL.
+    $expected_document['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // Ensure dynamic cache HIT on second request when using a version
+    // negotiator.
+    $actual_response = $this->request('GET', $latest_revision_id_url, $request_options);
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'HIT');
+    // Fetch the same revision using the `latest-version` link relation type
+    // negotiator. Without content_moderation, this is always the most recent
+    // revision.
+    $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
+    $expected_document['links']['self']['href'] = $rel_latest_version_url->setAbsolute()->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // Fetch the same revision using the `working-copy` link relation type
+    // negotiator. Without content_moderation, this is always the most recent
+    // revision.
+    $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
+    $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+
+    // Fetch the prior revision.
+    $actual_response = $this->request('GET', $original_revision_id_url, $request_options);
+    $expected_document['data']['attributes'][$revision_id_key] = $original_revision_id;
+    $expected_document['data']['attributes']['field_revisionable_number'] = 42;
+    $expected_document['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+
+    // Install content_moderation module.
+    $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.');
+
+    // Set up an editorial workflow.
+    $workflow = $this->createEditorialWorkflow();
+    $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle());
+    $workflow->save();
+
+    // Ensure the test entity has content_moderation fields attached to it.
+    /* @var \Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\Entity\TranslatableRevisionableInterface $entity */
+    $entity = $this->entityStorage->load($entity->id());
+
+    // Set the published moderation state on the test entity.
+    $entity->set('moderation_state', 'published');
+    $entity->setNewRevision();
+    $entity->save();
+    $published_revision_id = (int) $entity->getRevisionId();
+
+    // Fetch the published revision by using the `rel` version negotiator and
+    // the `latest-version` version argument. With content_moderation, this is
+    // now the most recent revision where the moderation state was the 'default'
+    // one.
+    $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
+    $expected_document['data']['attributes'][$revision_id_key] = $published_revision_id;
+    $expected_document['data']['attributes']['moderation_state'] = 'published';
+    $expected_document['data']['attributes'][$published_key] = TRUE;
+    $expected_document['data']['attributes']['field_revisionable_number'] = 99;
+    $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
+    $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // Fetch the collection URL using the `latest-version` version argument.
+    $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
+    $expected_response = $this->getExpectedCollectionResponse([$entity], $rel_latest_version_collection_url->toString(), $request_options);
+    $expected_collection_document = $expected_response->getResponseData();
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+    // Fetch the published revision by using the `working-copy` version
+    // argument. With content_moderation, this is always the most recent
+    // revision regardless of moderation state.
+    $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
+    $expected_document['links']['self']['href'] = $rel_working_copy_url->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // Fetch the collection URL using the `working-copy` version argument.
+    $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
+    $expected_collection_document['links']['self']['href'] = $rel_working_copy_collection_url->toString();
+    $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+    // @todo: remove the next assertion when Drupal core supports entity query access control on revisions.
+    $rel_working_copy_collection_url_filtered = clone $rel_working_copy_collection_url;
+    $rel_working_copy_collection_url_filtered->setOption('query', ['filter[foo]' => 'bar'] + $rel_working_copy_collection_url->getOption('query'));
+    $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options);
+    $filtered_collection_expected_cache_contexts = [
+      'url.path',
+      'url.query_args:filter',
+      'url.query_args:resourceVersion',
+      'url.site',
+    ];
+    $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);
+    // Fetch the collection URL using an invalid version identifier.
+    $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options);
+    $invalid_version_expected_cache_contexts = [
+      'url.path',
+      'url.query_args:resourceVersion',
+      'url.site',
+    ];
+    $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);
+
+    // Move the entity to its draft moderation state.
+    $entity->set('field_revisionable_number', 42);
+    // Change a relationship field so revisions can be tested on related and
+    // relationship routes.
+    $new_user = $this->createUser();
+    $new_user->save();
+    $entity->set('field_jsonapi_test_entity_ref', ['target_id' => $new_user->id()]);
+    $entity->set('moderation_state', 'draft');
+    $entity->setNewRevision();
+    $entity->save();
+    $draft_revision_id = (int) $entity->getRevisionId();
+
+    // The `latest-version` link should *still* reference the same revision
+    // since a draft is not a default revision.
+    $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
+    $expected_document['links']['self']['href'] = $rel_latest_version_url->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // And the same should be true for collections.
+    $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options);
+    $expected_collection_document['data'][0] = $expected_document['data'];
+    $expected_collection_document['links']['self']['href'] = $rel_latest_version_collection_url->toString();
+    $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+    // Ensure that the `latest-version` response is same as the default link,
+    // aside from the document's `self` link.
+    $actual_response = $this->request('GET', $url, $request_options);
+    $expected_document['links']['self']['href'] = $url->toString();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // And the same should be true for collections.
+    $actual_response = $this->request('GET', $collection_url, $request_options);
+    $expected_collection_document['links']['self']['href'] = $collection_url->toString();
+    $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+    // Now, the `working-copy` link should reference the draft revision. This
+    // is significant because without content_moderation, the two responses
+    // would still been the same.
+    //
+    // Access is checked before any special permissions are granted. This
+    // asserts a 403 forbidden if the user is not allowed to see unpublished
+    // content.
+    $result = $entity->access('view', $this->account, TRUE);
+    if (!$result->isAllowed()) {
+      $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
+      $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+      $expected_cache_tags = Cache::mergeTags($expected_cacheability->getCacheTags(), $entity->getCacheTags());
+      $expected_cache_contexts = $expected_cacheability->getCacheContexts();
+      $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.';
+      $message = $result instanceof AccessResultReasonInterface ? trim($detail . ' ' . $result->getReason()) : $detail;
+      $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+      // On the collection URL, we should expect to see the draft omitted from
+      // the collection.
+      $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
+      $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options);
+      $expected_collection_document = $expected_response->getResponseData();
+      $expected_collection_document['data'] = [];
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $access_denied_response = static::getAccessDeniedResponse($entity, $result, $url, NULL, $detail)->getResponseData();
+      static::addOmittedObject($expected_collection_document, static::errorsToOmittedObject($access_denied_response['errors']));
+      $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+    }
+
+    // Since additional permissions are required to see 'draft' entities,
+    // grant those permissions.
+    $this->grantPermissionsToTestedRole($this->getEditorialPermissions());
+
+    // Now, the `working-copy` link should be latest revision and be accessible.
+    $actual_response = $this->request('GET', $rel_working_copy_url, $request_options);
+    $expected_document['data']['attributes'][$revision_id_key] = $draft_revision_id;
+    $expected_document['data']['attributes']['moderation_state'] = 'draft';
+    $expected_document['data']['attributes'][$published_key] = FALSE;
+    $expected_document['data']['attributes']['field_revisionable_number'] = 42;
+    $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString();
+    $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected();
+    $expected_cache_tags = $this->getExpectedCacheTags();
+    $expected_cache_contexts = $this->getExpectedCacheContexts();
+    $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // And the collection response should also have the latest revision.
+    $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options);
+    $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options);
+    $expected_collection_document = $expected_response->getResponseData();
+    $expected_collection_document['data'] = [$expected_document['data']];
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+
+    // Test relationship responses.
+    // Fetch the prior revision's relationship URL.
+    $test_relationship_urls = [
+      [
+        NULL,
+        $relationship_url,
+        $related_url,
+      ],
+      [
+        $original_revision_id,
+        $original_revision_id_relationship_url,
+        $original_revision_id_related_url,
+      ],
+      [
+        $latest_revision_id,
+        $latest_revision_id_relationship_url,
+        $latest_revision_id_related_url,
+      ],
+      [
+        $published_revision_id,
+        $rel_latest_version_relationship_url,
+        $rel_latest_version_related_url,
+      ],
+      [
+        $draft_revision_id,
+        $rel_working_copy_relationship_url,
+        $rel_working_copy_related_url,
+      ],
+    ];
+    foreach ($test_relationship_urls as $revision_case) {
+      list($revision_id, $relationship_url, $related_url) = $revision_case;
+      // Load the revision that will be requested.
+      $this->entityStorage->resetCache([$entity->id()]);
+      $revision = is_null($revision_id)
+        ? $this->entityStorage->load($entity->id())
+        : $this->entityStorage->loadRevision($revision_id);
+      // Request the relationship resource without access to the relationship
+      // field.
+      $actual_response = $this->request('GET', $relationship_url, $request_options);
+      $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
+      $expected_document = $expected_response->getResponseData();
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $expected_document['errors'][0]['links']['via']['href'] = $relationship_url->toString();
+      $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts());
+      // Request the related route.
+      $actual_response = $this->request('GET', $related_url, $request_options);
+      // @todo: refactor self::getExpectedRelatedResponses() into a function which returns a single response.
+      $expected_response = $this->getExpectedRelatedResponses(['field_jsonapi_test_entity_ref'], $request_options, $revision)['field_jsonapi_test_entity_ref'];
+      $expected_document = $expected_response->getResponseData();
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $expected_document['errors'][0]['links']['via']['href'] = $related_url->toString();
+      $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts());
+    }
+    $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']);
+    foreach ($test_relationship_urls as $revision_case) {
+      list($revision_id, $relationship_url, $related_url) = $revision_case;
+      // Load the revision that will be requested.
+      $this->entityStorage->resetCache([$entity->id()]);
+      $revision = is_null($revision_id)
+        ? $this->entityStorage->load($entity->id())
+        : $this->entityStorage->loadRevision($revision_id);
+      // Request the relationship resource after granting access to the
+      // relationship field.
+      $actual_response = $this->request('GET', $relationship_url, $request_options);
+      $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision);
+      $expected_document = $expected_response->getResponseData();
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS');
+      // Request the related route.
+      $actual_response = $this->request('GET', $related_url, $request_options);
+      $expected_response = $this->getExpectedRelatedResponse('field_jsonapi_test_entity_ref', $request_options, $revision);
+      $expected_document = $expected_response->getResponseData();
+      $expected_cacheability = $expected_response->getCacheableMetadata();
+      $expected_document['links']['self']['href'] = $related_url->toString();
+      // MISS or UNCACHEABLE depends on data. It must not be HIT.
+      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+      $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    }
+  }
+
+  /**
+   * Decorates the expected response with included data and cache metadata.
+   *
+   * This adds the expected includes to the expected document and also builds
+   * the expected cacheability for those includes. It does so based of responses
+   * from the related routes for individual relationships.
+   *
+   * @param \Drupal\jsonapi\ResourceResponse $expected_response
+   *   The expected ResourceResponse.
+   * @param \Drupal\jsonapi\ResourceResponse[] $related_responses
+   *   The related ResourceResponses, keyed by relationship field names.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The decorated ResourceResponse.
+   */
+  protected static function decorateExpectedResponseForIncludedFields(ResourceResponse $expected_response, array $related_responses) {
+    $expected_document = $expected_response->getResponseData();
+    $expected_cacheability = $expected_response->getCacheableMetadata();
+    foreach ($related_responses as $related_response) {
+      $related_document = $related_response->getResponseData();
+      $expected_cacheability->addCacheableDependency($related_response->getCacheableMetadata());
+      $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response'])));
+      // If any of the related response documents had omitted items or errors,
+      // we should later expect the document to have omitted items as well.
+      if (!empty($related_document['errors'])) {
+        static::addOmittedObject($expected_document, static::errorsToOmittedObject($related_document['errors']));
+      }
+      if (!empty($related_document['meta']['omitted'])) {
+        static::addOmittedObject($expected_document, $related_document['meta']['omitted']);
+      }
+      if (isset($related_document['data'])) {
+        $related_data = $related_document['data'];
+        $related_resources = (static::isResourceIdentifier($related_data))
+          ? [$related_data]
+          : $related_data;
+        foreach ($related_resources as $related_resource) {
+          if (empty($expected_document['included']) || !static::collectionHasResourceIdentifier($related_resource, $expected_document['included'])) {
+            $expected_document['included'][] = $related_resource;
+          }
+        }
+      }
+    }
+    return (new ResourceResponse($expected_document))->addCacheableDependency($expected_cacheability);
+  }
+
+  /**
+   * Gets the expected individual ResourceResponse for GET.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The expected individual ResourceResponse.
+   */
+  protected function getExpectedGetIndividualResourceResponse($status_code = 200) {
+    $resource_response = new ResourceResponse($this->getExpectedDocument(), $status_code);
+    $cacheability = new CacheableMetadata();
+    $cacheability->setCacheContexts($this->getExpectedCacheContexts());
+    $cacheability->setCacheTags($this->getExpectedCacheTags());
+    return $resource_response->addCacheableDependency($cacheability);
+  }
+
+  /**
+   * Returns an array of sparse fields sets to test.
+   *
+   * @return array
+   *   An array of sparse field sets (an array of field names), keyed by a label
+   *   for the field set.
+   */
+  protected function getSparseFieldSets() {
+    $field_names = array_keys($this->entity->toArray());
+    $field_sets = [
+      'empty' => [],
+      'some' => array_slice($field_names, floor(count($field_names) / 2)),
+      'all' => $field_names,
+    ];
+    if ($this->entity instanceof EntityOwnerInterface) {
+      $field_sets['nested_empty_fieldset'] = $field_sets['empty'];
+      $field_sets['nested_fieldset_with_owner_fieldset'] = ['name', 'created'];
+    }
+    return $field_sets;
+  }
+
+  /**
+   * Gets a list of public relationship names for the resource type under test.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface|null $entity
+   *   (optional) The entity for which to get relationship field names.
+   *
+   * @return array
+   *   An array of relationship field names.
+   */
+  protected function getRelationshipFieldNames(EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    // Only content entity types can have relationships.
+    $fields = $entity instanceof ContentEntityInterface
+      ? iterator_to_array($entity)
+      : [];
+    return array_reduce($fields, function ($field_names, $field) {
+      /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+      if (static::isReferenceFieldDefinition($field->getFieldDefinition())) {
+        $field_names[] = $this->resourceType->getPublicName($field->getName());
+      }
+      return $field_names;
+    }, []);
+  }
+
+  /**
+   * Authorize the user under test with additional permissions to view includes.
+   *
+   * @return array
+   *   An array of special permissions to be granted for certain relationship
+   *   paths where the keys are relationships paths and values are an array of
+   *   permissions.
+   */
+  protected static function getIncludePermissions() {
+    return [];
+  }
+
+  /**
+   * Gets an array of permissions required to view and update any tested entity.
+   *
+   * @return string[]
+   *   An array of permission names.
+   */
+  protected function getEditorialPermissions() {
+    return ['view latest version', "view any unpublished content"];
+  }
+
+  /**
+   * Checks access for the given operation on the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to check field access.
+   * @param string $operation
+   *   The operation for which to check access.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which to check access.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The AccessResult.
+   */
+  protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // The default entity access control handler assumes that permissions do not
+    // change during the lifetime of a request and caches access results.
+    // However, we're changing permissions during a test run and need fresh
+    // results, so reset the cache.
+    \Drupal::entityTypeManager()->getAccessControlHandler($entity->getEntityTypeId())->resetCache();
+    return $entity->access($operation, $account, TRUE);
+  }
+
+  /**
+   * Checks access for the given field operation on the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to check field access.
+   * @param string $field_name
+   *   The field for which to check access.
+   * @param string $operation
+   *   The operation for which to check access.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which to check access.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The AccessResult.
+   */
+  protected static function entityFieldAccess(EntityInterface $entity, $field_name, $operation, AccountInterface $account) {
+    $entity_access = static::entityAccess($entity, $operation === 'edit' ? 'update' : 'view', $account);
+    $field_access = $entity->{$field_name}->access($operation, $account, TRUE);
+    return $entity_access->andIf($field_access);
+  }
+
+  /**
+   * Gets an array of of all nested include paths to be tested.
+   *
+   * @param int $depth
+   *   (optional) The maximum depth to which included paths should be nested.
+   *
+   * @return array
+   *   An array of nested include paths.
+   */
+  protected function getNestedIncludePaths($depth = 3) {
+    $get_nested_relationship_field_names = function (EntityInterface $entity, $depth, $path = "") use (&$get_nested_relationship_field_names) {
+      $relationship_field_names = $this->getRelationshipFieldNames($entity);
+      if ($depth > 0) {
+        $paths = [];
+        foreach ($relationship_field_names as $field_name) {
+          $next = ($path) ? "$path.$field_name" : $field_name;
+          $internal_field_name = $this->resourceType->getInternalName($field_name);
+          if ($target_entity = $entity->{$internal_field_name}->entity) {
+            $deep = $get_nested_relationship_field_names($target_entity, $depth - 1, $next);
+            $paths = array_merge($paths, $deep);
+          }
+          else {
+            $paths[] = $next;
+          }
+        }
+        return $paths;
+      }
+      return array_map(function ($target_name) use ($path) {
+        return "$path.$target_name";
+      }, $relationship_field_names);
+    };
+    return $get_nested_relationship_field_names($this->entity, $depth);
+  }
+
+  /**
+   * Determines if a given field definition is a reference field.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition to inspect.
+   *
+   * @return bool
+   *   TRUE if the field definition is found to be a reference field. FALSE
+   *   otherwise.
+   */
+  protected static function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) {
+    /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
+    $item_definition = $field_definition->getItemDefinition();
+    $main_property = $item_definition->getMainPropertyName();
+    $property_definition = $item_definition->getPropertyDefinition($main_property);
+    return $property_definition instanceof DataReferenceTargetDefinition;
+  }
+
+  /**
+   * Grants authorization to view includes.
+   *
+   * @param string[] $include_paths
+   *   An array of include paths for which to grant access.
+   */
+  protected function grantIncludedPermissions(array $include_paths = []) {
+    $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($include_paths));
+    $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', []));
+    // Always grant access to 'view' the test entity reference field.
+    $flattened_permissions[] = 'field_jsonapi_test_entity_ref view access';
+    $this->grantPermissionsToTestedRole($flattened_permissions);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ResponsiveImageStyleTest.php b/core/modules/jsonapi/tests/src/Functional/ResponsiveImageStyleTest.php
new file mode 100644
index 0000000000..298dc48d00
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResponsiveImageStyleTest.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\responsive_image\Entity\ResponsiveImageStyle;
+
+/**
+ * JSON:API integration test for the "ResponsiveImageStyle" config entity type.
+ *
+ * @group jsonapi
+ */
+class ResponsiveImageStyleTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['responsive_image'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'responsive_image_style';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'responsive_image_style--responsive_image_style';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\responsive_image\ResponsiveImageStyleInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer responsive images']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" responsive image style.
+    $camelids = ResponsiveImageStyle::create([
+      'id' => 'camelids',
+      'label' => 'Camelids',
+    ]);
+    $camelids->setBreakpointGroup('test_group');
+    $camelids->setFallbackImageStyle('fallback');
+    $camelids->addImageStyleMapping('test_breakpoint', '1x', [
+      'image_mapping_type' => 'image_style',
+      'image_mapping' => 'small',
+    ]);
+    $camelids->addImageStyleMapping('test_breakpoint', '2x', [
+      'image_mapping_type' => 'sizes',
+      'image_mapping' => [
+        'sizes' => '(min-width:700px) 700px, 100vw',
+        'sizes_image_styles' => [
+          'medium' => 'medium',
+          'large' => 'large',
+        ],
+      ],
+    ]);
+    $camelids->save();
+
+    return $camelids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/responsive_image_style/responsive_image_style/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'responsive_image_style--responsive_image_style',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'breakpoint_group' => 'test_group',
+          'dependencies' => [
+            'config' => [
+              'image.style.large',
+              'image.style.medium',
+            ],
+          ],
+          'fallback_image_style' => 'fallback',
+          'image_style_mappings' => [
+            0 => [
+              'breakpoint_id' => 'test_breakpoint',
+              'image_mapping' => 'small',
+              'image_mapping_type' => 'image_style',
+              'multiplier' => '1x',
+            ],
+            1 => [
+              'breakpoint_id' => 'test_breakpoint',
+              'image_mapping' => [
+                'sizes' => '(min-width:700px) 700px, 100vw',
+                'sizes_image_styles' => [
+                  'large' => 'large',
+                  'medium' => 'medium',
+                ],
+              ],
+              'image_mapping_type' => 'sizes',
+              'multiplier' => '2x',
+            ],
+          ],
+          'label' => 'Camelids',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'drupal_internal__id' => 'camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/RestExportJsonApiUnsupported.php b/core/modules/jsonapi/tests/src/Functional/RestExportJsonApiUnsupported.php
new file mode 100644
index 0000000000..5e83877119
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RestExportJsonApiUnsupported.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Tests\views\Functional\ViewTestBase;
+use Drupal\views\Tests\ViewTestData;
+
+/**
+ * Ensures that the 'api_json' format is not supported by the REST module.
+ *
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class RestExportJsonApiUnsupported extends ViewTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $testViews = ['test_serializer_display_entity'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['jsonapi', 'rest_test_views', 'views_ui'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp($import_test_views = TRUE) {
+    parent::setUp($import_test_views);
+    ViewTestData::createTestViews(get_class($this), ['rest_test_views']);
+
+    $this->drupalLogin($this->drupalCreateUser(['administer views']));
+  }
+
+  /**
+   * Tests that 'api_json' is not a RestExport format option.
+   */
+  public function testFormatOptions() {
+    $this->assertSame(['json' => 'serialization', 'xml' => 'serialization'], $this->container->getParameter('serializer.format_providers'));
+
+    $this->drupalGet('admin/structure/views/nojs/display/test_serializer_display_entity/rest_export_1/style_options');
+    $this->assertSession()->fieldExists('style_options[formats][json]');
+    $this->assertSession()->fieldExists('style_options[formats][xml]');
+    $this->assertSession()->fieldNotExists('style_options[formats][api_json]');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
new file mode 100644
index 0000000000..87c0f61b2c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
@@ -0,0 +1,128 @@
+<?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\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\ResourceTestBase;
+
+/**
+ * Ensures that the 'api_json' format is not supported by the REST module.
+ *
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class RestJsonApiUnsupported extends ResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['jsonapi', 'node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'api_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/vnd.api+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceConfigId = 'entity.node';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      default:
+        throw new \UnexpectedValueException();
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    // Set up a HTTP client that accepts relative URLs.
+    $this->httpClient = $this->container->get('http_client_factory')
+      ->fromOptions(['base_uri' => $this->baseUrl]);
+
+    // 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(0)
+      ->setPublished()
+      ->save();
+  }
+
+  /**
+   * Deploying a REST resource using api_json format results in 400 responses.
+   *
+   * @see \Drupal\jsonapi\EventSubscriber\JsonApiRequestValidator::validateQueryParams()
+   */
+  public function testApiJsonNotSupportedInRest() {
+    $this->assertSame(['json', 'xml'], $this->container->getParameter('serializer.formats'));
+
+    $this->provisionResource(['api_json'], []);
+    $this->setUpAuthorization('GET');
+
+    $url = Node::load(1)->toUrl()
+      ->setOption('query', ['_format' => 'api_json']);
+    $request_options = [];
+
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceErrorResponse(
+      400,
+      FALSE,
+      $response,
+      ['4xx-response', 'config:user.role.anonymous', 'http_response', 'node:1'],
+      ['url.query_args:_format', 'url.site', 'user.permissions'],
+      'MISS',
+      'MISS'
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {}
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedBcUnauthorizedAccessMessage($method) {}
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/RestResourceConfigTest.php b/core/modules/jsonapi/tests/src/Functional/RestResourceConfigTest.php
new file mode 100644
index 0000000000..f44ebd0dec
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RestResourceConfigTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\rest\Entity\RestResourceConfig;
+
+/**
+ * JSON:API integration test for the "RestResourceConfig" config entity type.
+ *
+ * @group jsonapi
+ */
+class RestResourceConfigTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['rest', 'dblog'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'rest_resource_config';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'rest_resource_config--rest_resource_config';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\rest\RestResourceConfigInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer rest resources']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $rest_resource_config = RestResourceConfig::create([
+      'id' => 'llama',
+      'plugin_id' => 'dblog',
+      'granularity' => 'method',
+      'configuration' => [
+        'GET' => [
+          'supported_formats' => [
+            'json',
+          ],
+          'supported_auth' => [
+            'cookie',
+          ],
+        ],
+      ],
+    ]);
+    $rest_resource_config->save();
+
+    return $rest_resource_config;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/rest_resource_config/rest_resource_config/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'rest_resource_config--rest_resource_config',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'dblog',
+              'serialization',
+              'user',
+            ],
+          ],
+          'plugin_id' => 'dblog',
+          'granularity' => 'method',
+          'configuration' => [
+            'GET' => [
+              'supported_formats' => [
+                'json',
+              ],
+              'supported_auth' => [
+                'cookie',
+              ],
+            ],
+          ],
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/RoleTest.php b/core/modules/jsonapi/tests/src/Functional/RoleTest.php
new file mode 100644
index 0000000000..4f8d609a22
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RoleTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\user\Entity\Role;
+
+/**
+ * JSON:API integration test for the "Role" config entity type.
+ *
+ * @group jsonapi
+ */
+class RoleTest extends ResourceTestBase {
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user_role';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'user_role--user_role';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\user\RoleInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer permissions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $role = Role::create([
+      'id' => 'llama',
+      'name' => $this->randomString(),
+    ]);
+    $role->save();
+
+    return $role;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/user_role/user_role/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user_role--user_role',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'weight' => 2,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'label' => NULL,
+          'is_admin' => NULL,
+          'permissions' => [],
+          'drupal_internal__id' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/SearchPageTest.php b/core/modules/jsonapi/tests/src/Functional/SearchPageTest.php
new file mode 100644
index 0000000000..4f741d6335
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/SearchPageTest.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\search\Entity\SearchPage;
+
+/**
+ * JSON:API integration test for the "SearchPage" config entity type.
+ *
+ * @group jsonapi
+ */
+class SearchPageTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'search'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'search_page';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'search_page--search_page';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\search\SearchPageInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer search']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $search_page = SearchPage::create([
+      'id' => 'hinode_search',
+      'plugin' => 'node_search',
+      'label' => 'Search of magnetic activity of the Sun',
+      'path' => 'sun',
+    ]);
+    $search_page->save();
+    return $search_page;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/search_page/search_page/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'search_page--search_page',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'configuration' => [
+            'rankings' => [],
+          ],
+          'dependencies' => [
+            'module' => [
+              'node',
+            ],
+          ],
+          'label' => 'Search of magnetic activity of the Sun',
+          'langcode' => 'en',
+          'path' => 'sun',
+          'plugin' => 'node_search',
+          'status' => TRUE,
+          'weight' => 0,
+          'drupal_internal__id' => 'hinode_search',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access content' permission is required.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\search\SearchPageAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['config:search.page.hinode_search']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ShortcutSetTest.php b/core/modules/jsonapi/tests/src/Functional/ShortcutSetTest.php
new file mode 100644
index 0000000000..0ab41a38b0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ShortcutSetTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\shortcut\Entity\ShortcutSet;
+
+/**
+ * JSON:API integration test for the "ShortcutSet" config entity type.
+ *
+ * @group jsonapi
+ */
+class ShortcutSetTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['shortcut'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'shortcut_set';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'shortcut_set--shortcut_set';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\shortcut\ShortcutSetInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access shortcuts']);
+        break;
+
+      case 'POST':
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['access shortcuts', 'customize shortcut links']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer shortcuts']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access shortcuts' permission is required.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $set = ShortcutSet::create([
+      'id' => 'llama_set',
+      'label' => 'Llama Set',
+    ]);
+    $set->save();
+    return $set;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/shortcut_set/shortcut_set/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'shortcut_set--shortcut_set',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'label' => 'Llama Set',
+          'status' => TRUE,
+          'langcode' => 'en',
+          'dependencies' => [],
+          'drupal_internal__id' => 'llama_set',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ShortcutTest.php b/core/modules/jsonapi/tests/src/Functional/ShortcutTest.php
new file mode 100644
index 0000000000..9fc53b988e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ShortcutTest.php
@@ -0,0 +1,220 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\shortcut\Entity\ShortcutSet;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "Shortcut" content entity type.
+ *
+ * @group jsonapi
+ */
+class ShortcutTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['comment', 'shortcut'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'shortcut';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'shortcut--default';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\shortcut\ShortcutInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['access shortcuts', 'customize shortcut links']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $shortcut = Shortcut::create([
+      'shortcut_set' => 'default',
+      'title' => t('Comments'),
+      'weight' => -20,
+      'link' => [
+        'uri' => 'internal:/user/logout',
+      ],
+    ]);
+    $shortcut->save();
+
+    return $shortcut;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/shortcut/default/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'shortcut--default',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'title' => 'Comments',
+          'link' => [
+            'uri' => 'internal:/user/logout',
+            'title' => NULL,
+            'options' => [],
+          ],
+          'langcode' => 'en',
+          'default_langcode' => TRUE,
+          'weight' => -20,
+          'drupal_internal__id' => (int) $this->entity->id(),
+        ],
+        'relationships' => [
+          'shortcut_set' => [
+            'data' => [
+              'type' => 'shortcut_set--shortcut_set',
+              'id' => ShortcutSet::load('default')->uuid(),
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/shortcut_set'],
+              'self' => ['href' => $self_url . '/relationships/shortcut_set'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'shortcut--default',
+        'attributes' => [
+          'title' => 'Comments',
+          'link' => [
+            'uri' => 'internal:/',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The shortcut set must be the currently displayed set for the user and the user must have 'access shortcuts' AND 'customize shortcut links' permissions.";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    $this->markTestSkipped('Disabled until https://www.drupal.org/project/drupal/issues/2982060 is fixed.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelationships() {
+    $this->markTestSkipped('Disabled until https://www.drupal.org/project/drupal/issues/2982060 is fixed.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    $this->markTestSkipped('Disabled until https://www.drupal.org/project/drupal/issues/2982060 is fixed.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $label_field_name = 'title';
+    // Verify the expected behavior in the common case: default shortcut set.
+    $this->grantPermissionsToTestedRole(['customize shortcut links']);
+    $this->doTestCollectionFilterAccessBasedOnPermissions($label_field_name, 'access shortcuts');
+
+    $alternate_shortcut_set = ShortcutSet::create([
+      'id' => 'alternate',
+      'label' => 'Alternate',
+    ]);
+    $alternate_shortcut_set->save();
+    $this->entity->shortcut_set = $alternate_shortcut_set->id();
+    $this->entity->save();
+
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    $collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // No results because the current user does not have access to shortcuts
+    // not in the user's assigned set or the default set.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+
+    // Assign the alternate shortcut set to the current user.
+    $this->container->get('entity_type.manager')->getStorage('shortcut_set')->assignUser($alternate_shortcut_set, $this->account);
+
+    // 1 result because the alternate shortcut set is now assigned to the
+    // current user.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($account, $collection, $sparse_fieldset, $filtered);
+    if ($filtered) {
+      $cacheability->addCacheContexts(['user']);
+    }
+    return $cacheability;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/TermTest.php b/core/modules/jsonapi/tests/src/Functional/TermTest.php
new file mode 100644
index 0000000000..a8b79a139d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TermTest.php
@@ -0,0 +1,457 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\jsonapi\Traits\CommonCollectionFilterAccessTestPatternsTrait;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "Term" content entity type.
+ *
+ * @group jsonapi
+ */
+class TermTest extends ResourceTestBase {
+
+  use CommonCollectionFilterAccessTestPatternsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_term';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'taxonomy_term--camelids';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create terms in camelids']);
+        break;
+
+      case 'PATCH':
+        // Grant the 'create url aliases' permission to test the case when
+        // the path field is accessible, see
+        // \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase
+        // for a negative test.
+        $this->grantPermissionsToTestedRole(['edit terms in camelids', 'create url aliases']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['delete terms in camelids']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::load('camelids');
+    if (!$vocabulary) {
+      // Create a "Camelids" vocabulary.
+      $vocabulary = Vocabulary::create([
+        'name' => 'Camelids',
+        'vid' => 'camelids',
+      ]);
+      $vocabulary->save();
+    }
+
+    // Create a "Llama" taxonomy term.
+    $term = Term::create(['vid' => $vocabulary->id()])
+      ->setName('Llama')
+      ->setDescription("It is a little known fact that llamas cannot count higher than seven.")
+      ->setChangedTime(123456789)
+      ->set('path', '/llama');
+    $term->save();
+
+    return $term;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/taxonomy_term/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+
+    // We test with multiple parent terms, and combinations thereof.
+    // @see ::createEntity()
+    // @see ::testGetIndividual()
+    // @see ::testGetIndividualTermWithParent()
+    // @see ::providerTestGetIndividualTermWithParent()
+    $parent_term_ids = [];
+    for ($i = 0; $i < $this->entity->get('parent')->count(); $i++) {
+      $parent_term_ids[$i] = (int) $this->entity->get('parent')[$i]->target_id;
+    }
+
+    $expected_parent_normalization = FALSE;
+    switch ($parent_term_ids) {
+      case [0]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => 'virtual',
+              'type' => 'taxonomy_term--camelids',
+              'meta' => [
+                'links' => [
+                  'help' => [
+                    'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
+                    'meta' => [
+                      'about' => "Usage and meaning of the 'virtual' resource identifier.",
+                    ],
+                  ],
+                ],
+              ],
+            ],
+          ],
+          'links' => [
+            'related' => ['href' => $self_url . '/parent'],
+            'self' => ['href' => $self_url . '/relationships/parent'],
+          ],
+        ];
+        break;
+
+      case [2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => ['href' => $self_url . '/parent'],
+            'self' => ['href' => $self_url . '/relationships/parent'],
+          ],
+        ];
+        break;
+
+      case [0, 2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => 'virtual',
+              'type' => 'taxonomy_term--camelids',
+              'meta' => [
+                'links' => [
+                  'help' => [
+                    'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
+                    'meta' => [
+                      'about' => "Usage and meaning of the 'virtual' resource identifier.",
+                    ],
+                  ],
+                ],
+              ],
+            ],
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => ['href' => $self_url . '/parent'],
+            'self' => ['href' => $self_url . '/relationships/parent'],
+          ],
+        ];
+        break;
+
+      case [3, 2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => Term::load(3)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => ['href' => $self_url . '/parent'],
+            'self' => ['href' => $self_url . '/relationships/parent'],
+          ],
+        ];
+        break;
+    }
+
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_term--camelids',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'default_langcode' => TRUE,
+          'description' => [
+            'value' => 'It is a little known fact that llamas cannot count higher than seven.',
+            'format' => NULL,
+            'processed' => "<p>It is a little known fact that llamas cannot count higher than seven.</p>\n",
+          ],
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'weight' => 0,
+          'drupal_internal__tid' => 1,
+          'status' => TRUE,
+        ],
+        'relationships' => [
+          'parent' => $expected_parent_normalization,
+          'vid' => [
+            'data' => [
+              'id' => Vocabulary::load('camelids')->uuid(),
+              'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+            ],
+            'links' => [
+              'related' => ['href' => $self_url . '/vid'],
+              'self' => ['href' => $self_url . '/relationships/vid'],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) {
+    $data = parent::getExpectedGetRelationshipDocumentData($relationship_field_name, $entity);
+    if ($relationship_field_name === 'parent') {
+      $data = [
+        0 => [
+          'id' => 'virtual',
+          'type' => 'taxonomy_term--camelids',
+          'meta' => [
+            'links' => [
+              'help' => [
+                'href' => 'https://www.drupal.org/docs/8/modules/json-api/core-concepts#virtual',
+                'meta' => [
+                  'about' => "Usage and meaning of the 'virtual' resource identifier.",
+                ],
+              ],
+            ],
+          ],
+        ],
+      ];
+    }
+    return $data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'taxonomy_term--camelids',
+        'attributes' => [
+          'name' => 'Dramallama',
+          'description' => [
+            'value' => 'Dramallamas are the coolest camelids.',
+            'format' => NULL,
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access content' permission is required and the taxonomy term must be published.";
+
+      case 'POST':
+        return "The following permissions are required: 'create terms in camelids' OR 'administer taxonomy'.";
+
+      case 'PATCH':
+        return "The following permissions are required: 'edit terms in camelids' OR 'administer taxonomy'.";
+
+      case 'DELETE':
+        return "The following permissions are required: 'delete terms in camelids' OR 'administer taxonomy'.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    $cacheability = parent::getExpectedUnauthorizedAccessCacheability();
+    $cacheability->addCacheableDependency($this->entity);
+    return $cacheability;
+  }
+
+  /**
+   * Tests PATCHing a term's path.
+   *
+   * For a negative test, see the similar test coverage for Node.
+   *
+   * @see \Drupal\Tests\jsonapi\Functional\NodeTest::testPatchPath()
+   * @see \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase::testPatchPath()
+   */
+  public function testPatchPath() {
+    $this->setUpAuthorization('GET');
+    $this->setUpAuthorization('PATCH');
+
+    // @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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // GET term's current normalization.
+    $response = $this->request('GET', $url, $request_options);
+    $normalization = Json::decode((string) $response->getBody());
+
+    // Change term's path alias.
+    $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
+
+    // Create term PATCH request.
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // PATCH request: 200.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $updated_normalization = Json::decode((string) $response->getBody());
+    $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags(array $sparse_fieldset = NULL) {
+    $tags = parent::getExpectedCacheTags($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('description', $sparse_fieldset)) {
+      $tags = Cache::mergeTags($tags, ['config:filter.format.plain_text', 'config:filter.settings']);
+    }
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    $contexts = parent::getExpectedCacheContexts($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('description', $sparse_fieldset)) {
+      $contexts = Cache::mergeContexts($contexts, ['languages:language_interface', 'theme']);
+    }
+    return $contexts;
+  }
+
+  /**
+   * Tests GETting a term with a parent term other than the default <root> (0).
+   *
+   * @see ::getExpectedNormalizedEntity()
+   *
+   * @dataProvider providerTestGetIndividualTermWithParent
+   */
+  public function testGetIndividualTermWithParent(array $parent_term_ids) {
+    // Create all possible parent terms.
+    Term::create(['vid' => Vocabulary::load('camelids')->id()])
+      ->setName('Lamoids')
+      ->save();
+    Term::create(['vid' => Vocabulary::load('camelids')->id()])
+      ->setName('Wimoids')
+      ->save();
+
+    // Modify the entity under test to use the provided parent terms.
+    $this->entity->set('parent', $parent_term_ids)->save();
+
+    // @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), ['entity' => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $this->setUpAuthorization('GET');
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSameDocument($this->getExpectedDocument(), Json::decode($response->getBody()));
+  }
+
+  /**
+   * Data provider for ::testGetIndividualTermWithParent().
+   */
+  public function providerTestGetIndividualTermWithParent() {
+    return [
+      'root parent: [0] (= no parent)' => [
+        [0],
+      ],
+      'non-root parent: [2]' => [
+        [2],
+      ],
+      'multiple parents: [0,2] (root + non-root parent)' => [
+        [0, 2],
+      ],
+      'multiple parents: [3,2] (both non-root parents)' => [
+        [3, 2],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    $this->doTestCollectionFilterAccessBasedOnPermissions('name', 'access content');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/TestCoverageTest.php b/core/modules/jsonapi/tests/src/Functional/TestCoverageTest.php
new file mode 100644
index 0000000000..217d3d0d93
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TestCoverageTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Checks that all core content/config entity types have JSON:API test coverage.
+ *
+ * @group jsonapi
+ */
+class TestCoverageTest extends BrowserTestBase {
+
+  /**
+   * Entity definitions array.
+   *
+   * @var array
+   */
+  protected $definitions;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $all_modules = system_rebuild_module_data();
+    $stable_core_modules = array_filter($all_modules, function ($module) {
+      // Filter out contrib, hidden, testing, and experimental modules. We also
+      // don't need to enable modules that are already enabled.
+      return $module->origin === 'core'
+        && empty($module->info['hidden'])
+        && $module->status == FALSE
+        && $module->info['package'] !== 'Testing'
+        && $module->info['package'] !== 'Core (Experimental)';
+    });
+
+    $this->container->get('module_installer')->install(array_keys($stable_core_modules));
+    $this->rebuildContainer();
+
+    $this->definitions = $this->container->get('entity_type.manager')->getDefinitions();
+
+    // Entity types marked as "internal" are not exposed by JSON:API and hence
+    // also don't need test coverage.
+    $this->definitions = array_filter($this->definitions, function (EntityTypeInterface $entity_type) {
+      return !$entity_type->isInternal();
+    });
+  }
+
+  /**
+   * Tests that all core entity types have JSON:API test coverage.
+   */
+  public function testEntityTypeRestTestCoverage() {
+    $problems = [];
+    foreach ($this->definitions as $entity_type_id => $info) {
+      $class_name_full = $info->getClass();
+      $parts = explode('\\', $class_name_full);
+      $class_name = end($parts);
+      $module_name = $parts[1];
+
+      $possible_paths = [
+        'Drupal\Tests\jsonapi\Functional\CLASSTest',
+        '\Drupal\Tests\\' . $module_name . '\Functional\Jsonapi\CLASSTest',
+      ];
+      foreach ($possible_paths as $path) {
+        $missing_tests = [];
+        $class = str_replace('CLASS', $class_name, $path);
+        if (class_exists($class)) {
+          continue 2;
+        }
+        $missing_tests[] = $class;
+      }
+      if (!empty($missing_tests)) {
+        $missing_tests_list = implode(', ', $missing_tests);
+        $problems[] = "$entity_type_id: $class_name ($class_name_full) (expected tests: $missing_tests_list)";
+      }
+    }
+
+    $all = count($this->definitions);
+    $good = $all - count($problems);
+    $this->assertSame([], $problems, $this->getLlamaMessage($good, $all));
+  }
+
+  /**
+   * Message from Llama.
+   *
+   * @param int $g
+   *   A count of entities with test coverage.
+   * @param int $a
+   *   A count of all entities.
+   *
+   * @return string
+   *   An information about progress of REST test coverage.
+   */
+  protected function getLlamaMessage($g, $a) {
+    return "
+☼
+      _________________________
+     /           Hi!           \\
+    |  It's llame to not have   |
+    |  complete JSON:API tests! |
+    |                           |
+    |     Progress: $g/$a.      |
+    | _________________________/
+    |/
+//  o
+l'>
+ll
+llama
+|| ||
+'' ''
+";
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/TourTest.php b/core/modules/jsonapi/tests/src/Functional/TourTest.php
new file mode 100644
index 0000000000..e6d3488778
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TourTest.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\tour\Entity\Tour;
+
+/**
+ * JSON:API integration test for the "Tour" config entity type.
+ *
+ * @group jsonapi
+ */
+class TourTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['tour'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'tour';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'tour--tour';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\tour\TourInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['access tour']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $tour = Tour::create([
+      'id' => 'tour-llama',
+      'label' => 'Llama tour',
+      'langcode' => 'en',
+      'module' => 'tour',
+      'routes' => [
+        [
+          'route_name' => '<front>',
+        ],
+      ],
+      'tips' => [
+        'tour-llama-1' => [
+          'id' => 'tour-llama-1',
+          'plugin' => 'text',
+          'label' => 'Llama',
+          'body' => 'Who handle the awesomeness of llamas?',
+          'weight' => 100,
+          'attributes' => [
+            'data-id' => 'tour-llama-1',
+          ],
+        ],
+      ],
+    ]);
+    $tour->save();
+
+    return $tour;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/tour/tour/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'tour--tour',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'label' => 'Llama tour',
+          'langcode' => 'en',
+          'module' => 'tour',
+          'routes' => [
+            [
+              'route_name' => '<front>',
+            ],
+          ],
+          'status' => TRUE,
+          'tips' => [
+            'tour-llama-1' => [
+              'id' => 'tour-llama-1',
+              'plugin' => 'text',
+              'label' => 'Llama',
+              'body' => 'Who handle the awesomeness of llamas?',
+              'weight' => 100,
+              'attributes' => [
+                'data-id' => 'tour-llama-1',
+              ],
+            ],
+          ],
+          'drupal_internal__id' => 'tour-llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    return "The following permissions are required: 'access tour' OR 'administer site configuration'.";
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/UserTest.php b/core/modules/jsonapi/tests/src/Functional/UserTest.php
new file mode 100644
index 0000000000..8b99779411
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php
@@ -0,0 +1,546 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\node\Entity\Node;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON:API integration test for the "User" content entity type.
+ *
+ * @group jsonapi
+ */
+class UserTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'user--user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $anonymousUsersCanViewLabels = TRUE;
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $labelFieldName = 'name';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $firstCreatedEntityId = 4;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $secondCreatedEntityId = 5;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    // @todo Remove this in
+    $this->grantPermissionsToTestedRole(['access content']);
+
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access user profiles']);
+        break;
+
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer users']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Llama" user.
+    $user = User::create(['created' => 123456789]);
+    $user->setUsername('Llama')
+      ->setChangedTime(123456789)
+      ->activate()
+      ->save();
+
+    return $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity($key) {
+    /** @var \Drupal\user\UserInterface $user */
+    $user = $this->getEntityDuplicate($this->entity, $key);
+    $user->setUsername($user->label() . '_' . $key);
+    $user->setEmail("$key@example.com");
+    $user->save();
+    return $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/user/user/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user--user',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'created' => '1973-11-29T21:33:09+00:00',
+          'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'drupal_internal__uid' => 3,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) {
+    $cache_contexts = parent::getExpectedCacheContexts($sparse_fieldset);
+    if ($sparse_fieldset === NULL || in_array('mail', $sparse_fieldset)) {
+      if (floatval(\Drupal::VERSION) >= 8.7) {
+        $cache_contexts = Cache::mergeContexts($cache_contexts, ['user']);
+      }
+    }
+    return $cache_contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'user--user',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access user profiles' permission is required and the user must be active.";
+
+      case 'PATCH':
+        return "Users can only update their own account, unless they have the 'administer users' permission.";
+
+      case 'DELETE':
+        return "The 'cancel account' permission is required.";
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields of the logged in account.
+   */
+  public function testPatchDxForSecuritySensitiveBaseFields() {
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $this->account->uuid()]);
+    /* $url = $this->account->toUrl('jsonapi'); */
+
+    $original_normalization = $this->normalize($this->account, $url);
+    // @todo Remove the array_diff_key() call in https://www.drupal.org/node/2821077.
+    $original_normalization['data']['attributes'] = array_diff_key(
+      $original_normalization['data']['attributes'],
+      ['created' => TRUE, 'changed' => TRUE, 'name' => TRUE]
+    );
+
+    // Since this test must be performed by the user that is being modified,
+    // we must use $this->account, not $this->entity.
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Test case 1: changing email.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing email without providing the password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(422, 'mail: Your current password is missing or incorrect; it\'s required to change the Email.', NULL, $response, '/data/attributes/mail');
+
+    $normalization['data']['attributes']['pass']['existing'] = 'wrong';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing email while providing a wrong password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(422, 'mail: Your current password is missing or incorrect; it\'s required to change the Email.', NULL, $response, '/data/attributes/mail');
+
+    $normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+    // Test case 2: changing password.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $new_password = $this->randomString();
+    $normalization['data']['attributes']['pass']['value'] = $new_password;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing password without providing the current password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(422, 'pass: Your current password is missing or incorrect; it\'s required to change the Password.', NULL, $response, '/data/attributes/pass');
+
+    $normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+    // Verify that we can log in with the new password.
+    $this->assertRpcLogin($this->account->getAccountName(), $new_password);
+
+    // Update password in $this->account, prepare for future requests.
+    $this->account->passRaw = $new_password;
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Test case 3: changing name.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $normalization['data']['attributes']['pass']['existing'] = $new_password;
+    $normalization['data']['attributes']['name'] = 'Cooler Llama';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 403 when modifying username without required permission.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'The current user is not allowed to PATCH the selected field (name).', $url, $response, '/data/attributes/name');
+
+    $this->grantPermissionsToTestedRole(['change own username']);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+    // Verify that we can log in with the new username.
+    $this->assertRpcLogin('Cooler Llama', $new_password);
+  }
+
+  /**
+   * Verifies that logging in with the given username and password works.
+   *
+   * @param string $username
+   *   The username to log in with.
+   * @param string $password
+   *   The password to log in with.
+   */
+  protected function assertRpcLogin($username, $password) {
+    $request_body = [
+      'name' => $username,
+      'pass' => $password,
+    ];
+    $request_options = [
+      RequestOptions::HEADERS => [],
+      RequestOptions::BODY => Json::encode($request_body),
+    ];
+    $response = $this->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json'), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields to change other users.
+   */
+  public function testPatchSecurityOtherUser() {
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $this->account->uuid()]);
+    /* $url = $this->account->toUrl('jsonapi'); */
+
+    $original_normalization = $this->normalize($this->account, $url);
+
+    // Since this test must be performed by the user that is being modified,
+    // we must use $this->account, not $this->entity.
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // Try changing user 1's email.
+    $user1 = $original_normalization;
+    $user1['data']['attributes']['mail'] = 'another_email_address@example.com';
+    $user1['data']['attributes']['uid'] = 1;
+    $user1['data']['attributes']['name'] = 'another_user_name';
+    $user1['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($user1);
+    $response = $this->request('PATCH', $url, $request_options);
+    // Ensure the email address has not changed.
+    $this->assertEquals('admin@example.com', $this->entityStorage->loadUnchanged(1)->getEmail());
+    $this->assertResourceErrorResponse(403, 'The current user is not allowed to PATCH the selected field (uid). The entity ID cannot be changed.', $url, $response, '/data/attributes/uid');
+  }
+
+  /**
+   * Tests GETting privacy-sensitive base fields.
+   */
+  public function testGetMailFieldOnlyVisibleToOwner() {
+    // Create user B, with the same roles (and hence permissions) as user A.
+    $user_a = $this->account;
+    $pass = user_password();
+    $user_b = User::create([
+      'name' => 'sibling-of-' . $user_a->getAccountName(),
+      'mail' => 'sibling-of-' . $user_a->getAccountName() . '@example.com',
+      'pass' => $pass,
+      'status' => 1,
+      'roles' => $user_a->getRoles(),
+    ]);
+    $user_b->save();
+    $user_b->passRaw = $pass;
+
+    // Grant permission to role that both users use.
+    $this->grantPermissionsToTestedRole(['access user profiles']);
+
+    $collection_url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['sort' => 'drupal_internal__uid']]);
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $user_a_url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['entity' => $user_a->uuid()]);
+    /* $user_a_url = $user_a->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Viewing user A as user A: "mail" field is accessible.
+    $response = $this->request('GET', $user_a_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertArrayHasKey('mail', $doc['data']['attributes']);
+    // Also when looking at the collection.
+    $response = $this->request('GET', $collection_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertSame($user_a->uuid(), $doc['data']['2']['id']);
+    $this->assertArrayHasKey('mail', $doc['data'][2]['attributes'], "Own user--user resource's 'mail' field is visible.");
+    $this->assertSame($user_b->uuid(), $doc['data'][count($doc['data']) - 1]['id']);
+    $this->assertArrayNotHasKey('mail', $doc['data'][count($doc['data']) - 1]['attributes']);
+
+    // Now request the same URLs, but as user B (same roles/permissions).
+    $this->account = $user_b;
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    // Viewing user A as user B: "mail" field should be inaccessible.
+    $response = $this->request('GET', $user_a_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertArrayNotHasKey('mail', $doc['data']['attributes']);
+    // Also when looking at the collection.
+    $response = $this->request('GET', $collection_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertSame($user_a->uuid(), $doc['data']['2']['id']);
+    $this->assertArrayNotHasKey('mail', $doc['data'][2]['attributes']);
+    $this->assertSame($user_b->uuid(), $doc['data'][count($doc['data']) - 1]['id']);
+    $this->assertArrayHasKey('mail', $doc['data'][count($doc['data']) - 1]['attributes']);
+  }
+
+  /**
+   * Test good error DX when trying to filter users by role.
+   */
+  public function testQueryInvolvingRoles() {
+    $this->setUpAuthorization('GET');
+
+    $collection_url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['filter[roles.id][value]' => 'e9b1de3f-9517-4c27-bef0-0301229de792']]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // The 'administer users' permission is required to filter by role entities.
+    $this->grantPermissionsToTestedRole(['administer users']);
+
+    $response = $this->request('GET', $collection_url, $request_options);
+    $expected_cache_contexts = ['url.path', 'url.query_args:filter', 'url.site'];
+    $this->assertResourceErrorResponse(400, "Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a Role config entity.", $collection_url, $response, FALSE, ['4xx-response', 'http_response'], $expected_cache_contexts, FALSE, 'MISS');
+  }
+
+  /**
+   * Tests that the collection contains the anonymous user.
+   */
+  public function testCollectionContainsAnonymousUser() {
+    $url = Url::fromRoute('jsonapi.user--user.collection', [], ['query' => ['sort' => 'drupal_internal__uid']]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    $response = $this->request('GET', $url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+
+    $this->assertCount(4, $doc['data']);
+    $this->assertSame(User::load(0)->uuid(), $doc['data'][0]['id']);
+    $this->assertSame('Anonymous', $doc['data'][0]['attributes']['name']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testCollectionFilterAccess() {
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['node'], TRUE), 'Installed modules.');
+    FieldStorageConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_favorite_animal',
+      'type' => 'string',
+    ])
+      ->setCardinality(1)
+      ->save();
+    FieldConfig::create([
+      'entity_type' => static::$entityTypeId,
+      'field_name' => 'field_favorite_animal',
+      'bundle' => 'user',
+    ])
+      ->setLabel('Test field')
+      ->setTranslatable(FALSE)
+      ->save();
+    $this->drupalCreateContentType(['type' => 'x']);
+    $this->rebuildAll();
+    $this->grantPermissionsToTestedRole(['access content']);
+
+    // Create data.
+    $user_a = User::create([])->setUsername('A')->activate();
+    $user_a->save();
+    $user_b = User::create([])->setUsername('B')->set('field_favorite_animal', 'stegosaurus')->block();
+    $user_b->save();
+    $node_a = Node::create(['type' => 'x'])->setTitle('Owned by A')->setOwner($user_a);
+    $node_a->save();
+    $node_b = Node::create(['type' => 'x'])->setTitle('Owned by B')->setOwner($user_b);
+    $node_b->save();
+    $node_anon_1 = Node::create(['type' => 'x'])->setTitle('Owned by anon #1')->setOwnerId(0);
+    $node_anon_1->save();
+    $node_anon_2 = Node::create(['type' => 'x'])->setTitle('Owned by anon #2')->setOwnerId(0);
+    $node_anon_2->save();
+    $node_auth_1 = Node::create(['type' => 'x'])->setTitle('Owned by auth #1')->setOwner($this->account);
+    $node_auth_1->save();
+
+    $favorite_animal_test_url = Url::fromRoute('jsonapi.user--user.collection')->setOption('query', ['filter[field_favorite_animal]' => 'stegosaurus']);
+
+    // Test.
+    $collection_url = Url::fromRoute('jsonapi.node--x.collection');
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    // ?filter[uid.id]=OWN_UUID requires no permissions: 1 result.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => $this->account->uuid()]), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($node_auth_1->uuid(), $doc['data'][0]['id']);
+    // ?filter[uid.id]=ANONYMOUS_UUID: 0 results.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => User::load(0)->uuid()]), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // ?filter[uid.name]=A: 0 results.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'A']), $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // /jsonapi/user/user?filter[field_favorite_animal]: 0 results.
+    $response = $this->request('GET', $favorite_animal_test_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // Grant "view" permission.
+    $this->grantPermissionsToTestedRole(['access user profiles']);
+    // ?filter[uid.id]=ANONYMOUS_UUID: 0 results.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.id]' => User::load(0)->uuid()]), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // ?filter[uid.name]=A: 1 result since user A is active.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'A']), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($node_a->uuid(), $doc['data'][0]['id']);
+    // ?filter[uid.name]=B: 0 results since user B is blocked.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'B']), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // /jsonapi/user/user?filter[field_favorite_animal]: 0 results.
+    $response = $this->request('GET', $favorite_animal_test_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // Grant "admin" permission.
+    $this->grantPermissionsToTestedRole(['administer users']);
+    // ?filter[uid.name]=B: 1 result.
+    $response = $this->request('GET', $collection_url->setOption('query', ['filter[uid.name]' => 'B']), $request_options);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'user.permissions');
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($node_b->uuid(), $doc['data'][0]['id']);
+    // /jsonapi/user/user?filter[field_favorite_animal]: 1 result.
+    $response = $this->request('GET', $favorite_animal_test_url, $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($user_b->uuid(), $doc['data'][0]['id']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/ViewTest.php b/core/modules/jsonapi/tests/src/Functional/ViewTest.php
new file mode 100644
index 0000000000..acd8f86cb2
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ViewTest.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\views\Entity\View;
+
+/**
+ * JSON:API integration test for the "View" config entity type.
+ *
+ * @group jsonapi
+ */
+class ViewTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['views'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'view';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'view--view';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\views\ViewEntityInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer views']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $view = View::create([
+      'id' => 'test_rest',
+      'label' => 'Test REST',
+    ]);
+    $view->save();
+    return $view;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/view/view/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'view--view',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'base_field' => 'nid',
+          'base_table' => 'node',
+          'core' => '8.x',
+          'dependencies' => [],
+          'description' => '',
+          'display' => [
+            'default' => [
+              'display_plugin' => 'default',
+              'id' => 'default',
+              'display_title' => 'Master',
+              'position' => 0,
+              'display_options' => [
+                'display_extenders' => [],
+              ],
+              'cache_metadata' => [
+                'max-age' => -1,
+                'contexts' => [
+                  'languages:language_interface',
+                  'url.query_args',
+                ],
+                'tags' => [],
+              ],
+            ],
+          ],
+          'label' => 'Test REST',
+          'langcode' => 'en',
+          'module' => 'views',
+          'status' => TRUE,
+          'tag' => '',
+          'drupal_internal__id' => 'test_rest',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/VocabularyTest.php b/core/modules/jsonapi/tests/src/Functional/VocabularyTest.php
new file mode 100644
index 0000000000..61ecdc811a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/VocabularyTest.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\taxonomy\Entity\Vocabulary;
+
+/**
+ * JSON:API integration test for the "vocabulary" config entity type.
+ *
+ * @group jsonapi
+ */
+class VocabularyTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_vocabulary';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'taxonomy_vocabulary--taxonomy_vocabulary';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer taxonomy']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::create([
+      'name' => 'Llama',
+      'vid' => 'llama',
+    ]);
+    $vocabulary->save();
+
+    return $vocabulary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/taxonomy_vocabulary/taxonomy_vocabulary/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'name' => 'Llama',
+          'description' => NULL,
+          'weight' => 0,
+          'drupal_internal__vid' => 'llama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($method === 'GET') {
+      return "The following permissions are required: 'access taxonomy overview' OR 'administer taxonomy'.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Functional/WorkflowTest.php b/core/modules/jsonapi/tests/src/Functional/WorkflowTest.php
new file mode 100644
index 0000000000..6ad93d50f7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/WorkflowTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\workflows\Entity\Workflow;
+
+/**
+ * JSON:API integration test for the "Workflow" config entity type.
+ *
+ * @group jsonapi
+ */
+class WorkflowTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workflows', 'workflow_type_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'workflow';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'workflow--workflow';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\shortcut\ShortcutSetInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer workflows']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $workflow = Workflow::create([
+      'id' => 'rest_workflow',
+      'label' => 'REST Worklow',
+      'type' => 'workflow_type_complex_test',
+    ]);
+    $workflow
+      ->getTypePlugin()
+      ->addState('draft', 'Draft')
+      ->addState('published', 'Published');
+    $configuration = $workflow->getTypePlugin()->getConfiguration();
+    $configuration['example_setting'] = 'foo';
+    $configuration['states']['draft']['extra'] = 'bar';
+    $workflow->getTypePlugin()->setConfiguration($configuration);
+    $workflow->save();
+    return $workflow;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/workflow/workflow/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => ['href' => $self_url],
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'workflow--workflow',
+        'links' => [
+          'self' => ['href' => $self_url],
+        ],
+        'attributes' => [
+          'dependencies' => [
+            'module' => [
+              'workflow_type_test',
+            ],
+          ],
+          'label' => 'REST Worklow',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'workflow_type' => 'workflow_type_complex_test',
+          'type_settings' => [
+            'states' => [
+              'draft' => [
+                'extra' => 'bar',
+                'label' => 'Draft',
+                'weight' => 0,
+              ],
+              'published' => [
+                'label' => 'Published',
+                'weight' => 1,
+              ],
+            ],
+            'transitions' => [],
+            'example_setting' => 'foo',
+          ],
+          'drupal_internal__id' => 'rest_workflow',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Context/FieldResolverTest.php b/core/modules/jsonapi/tests/src/Kernel/Context/FieldResolverTest.php
new file mode 100644
index 0000000000..50aae4a82e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Context/FieldResolverTest.php
@@ -0,0 +1,371 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Context;
+
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\entity_test\Entity\EntityTestBundle;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Context\FieldResolver
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class FieldResolverTest extends JsonapiKernelTestBase {
+
+  public static $modules = [
+    'entity_test',
+    'jsonapi_test_field_filter_access',
+    'serialization',
+    'field',
+    'text',
+    'user',
+  ];
+
+  /**
+   * The subject under test.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $sut;
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->sut = \Drupal::service('jsonapi.field_resolver');
+
+    $this->makeBundle('bundle1');
+    $this->makeBundle('bundle2');
+    $this->makeBundle('bundle3');
+
+    $this->makeField('string', 'field_test1', 'entity_test_with_bundle', ['bundle1']);
+    $this->makeField('string', 'field_test2', 'entity_test_with_bundle', ['bundle1']);
+    $this->makeField('string', 'field_test3', 'entity_test_with_bundle', ['bundle2', 'bundle3']);
+
+    // Provides entity reference fields.
+    $settings = ['target_type' => 'entity_test_with_bundle'];
+    $this->makeField('entity_reference', 'field_test_ref1', 'entity_test_with_bundle', ['bundle1'], $settings, [
+      'handler_settings' => [
+        'target_bundles' => ['bundle2', 'bundle3'],
+      ],
+    ]);
+    $this->makeField('entity_reference', 'field_test_ref2', 'entity_test_with_bundle', ['bundle1'], $settings);
+    $this->makeField('entity_reference', 'field_test_ref3', 'entity_test_with_bundle', ['bundle2', 'bundle3'], $settings);
+
+    // Add a field with multiple properties.
+    $this->makeField('text', 'field_test_text', 'entity_test_with_bundle', ['bundle1', 'bundle2']);
+
+    $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
+  }
+
+  /**
+   * @covers ::resolveInternalEntityQueryPath
+   * @dataProvider resolveInternalIncludePathProvider
+   */
+  public function testResolveInternalIncludePath($expect, $external_path, $entity_type_id = 'entity_test_with_bundle', $bundle = 'bundle1') {
+    $path_parts = explode('.', $external_path);
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    $this->assertEquals($expect, $this->sut->resolveInternalIncludePath($resource_type, $path_parts));
+  }
+
+  /**
+   * Provides test cases for resolveInternalEntityQueryPath.
+   */
+  public function resolveInternalIncludePathProvider() {
+    return [
+      'entity reference' => [[['field_test_ref2']], 'field_test_ref2'],
+      'entity reference with multi target bundles' => [[['field_test_ref1']], 'field_test_ref1'],
+      'entity reference then another entity reference' => [
+         [['field_test_ref1', 'field_test_ref3']],
+        'field_test_ref1.field_test_ref3',
+      ],
+    ];
+  }
+
+  /**
+   * Expects an error when an invalid field is provided for include.
+   *
+   * @param string $entity_type
+   *   The entity type for which to test field resolution.
+   * @param string $bundle
+   *   The entity bundle for which to test field resolution.
+   * @param string $external_path
+   *   The external field path to resolve.
+   * @param string $expected_message
+   *   (optional) An expected exception message.
+   *
+   * @covers ::resolveInternalIncludePath
+   * @dataProvider resolveInternalIncludePathErrorProvider
+   */
+  public function testResolveInternalIncludePathError($entity_type, $bundle, $external_path, $expected_message = '') {
+    $path_parts = explode('.', $external_path);
+    $this->setExpectedException(CacheableBadRequestHttpException::class, $expected_message);
+    $resource_type = $this->resourceTypeRepository->get($entity_type, $bundle);
+    $this->sut->resolveInternalIncludePath($resource_type, $path_parts);
+  }
+
+  /**
+   * Provides test cases for ::testResolveInternalIncludePathError.
+   */
+  public function resolveInternalIncludePathErrorProvider() {
+    return [
+      // Should fail because none of these bundles have these fields.
+      ['entity_test_with_bundle', 'bundle1', 'host.fail!!.deep'],
+      ['entity_test_with_bundle', 'bundle2', 'field_test_ref2'],
+      ['entity_test_with_bundle', 'bundle1', 'field_test_ref3'],
+      // Should fail because the nested fields don't exist on the targeted
+      // resource types.
+      ['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test1'],
+      ['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test2'],
+      ['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref1'],
+      ['entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref2'],
+      // Should fail because the nested fields is not a valid relationship
+      // field name.
+      [
+        'entity_test_with_bundle', 'bundle1', 'field_test1',
+        '`field_test1` is not a valid relationship field name.',
+      ],
+      // Should fail because the nested fields is not a valid include path.
+      [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test3',
+        '`field_test_ref1.field_test3` is not a valid include path.',
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::resolveInternalEntityQueryPath
+   * @dataProvider resolveInternalEntityQueryPathProvider
+   */
+  public function testResolveInternalEntityQueryPath($expect, $external_path, $entity_type_id = 'entity_test_with_bundle', $bundle = 'bundle1') {
+    $this->assertEquals($expect, $this->sut->resolveInternalEntityQueryPath($entity_type_id, $bundle, $external_path));
+  }
+
+  /**
+   * Provides test cases for ::testResolveInternalEntityQueryPath.
+   */
+  public function resolveInternalEntityQueryPathProvider() {
+    return [
+      'config entity as base' => [
+        'uuid', 'id', 'entity_test_bundle', 'entity_test_bundle',
+      ],
+      'config entity as target' => ['type.entity:entity_test_bundle.uuid', 'type.id'],
+
+      'primitive field; variation A' => ['field_test1', 'field_test1'],
+      'primitive field; variation B' => ['field_test2', 'field_test2'],
+
+      'entity reference then a primitive field; variation A' => ['field_test_ref2.entity:entity_test_with_bundle.field_test1', 'field_test_ref2.field_test1'],
+      'entity reference then a primitive field; variation B' => ['field_test_ref2.entity:entity_test_with_bundle.field_test2', 'field_test_ref2.field_test2'],
+
+      'entity reference then a complex field with property specifier `value`' => ['field_test_ref2.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref2.field_test_text.value'],
+      'entity reference then a complex field with property specifier `format`' => ['field_test_ref2.entity:entity_test_with_bundle.field_test_text.format', 'field_test_ref2.field_test_text.format'],
+
+      'entity reference then no delta with property specifier `id`' => ['field_test_ref1.entity:entity_test_with_bundle.uuid', 'field_test_ref1.id'],
+      'entity reference then delta 0 with property specifier `id`' => ['field_test_ref1.0.entity:entity_test_with_bundle.uuid', 'field_test_ref1.0.id'],
+      'entity reference then delta 1 with property specifier `id`' => ['field_test_ref1.1.entity:entity_test_with_bundle.uuid', 'field_test_ref1.1.id'],
+
+      'entity reference then no reference property and a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.field_test_text.value'],
+      'entity reference then a reference property and a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_text.value', 'field_test_ref1.entity.field_test_text.value'],
+      'entity reference then no reference property and a complex field with property specifier `format`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.format', 'field_test_ref1.field_test_text.format'],
+      'entity reference then a reference property and a complex field with property specifier `format`' => ['field_test_ref1.entity.field_test_text.format', 'field_test_ref1.entity.field_test_text.format'],
+
+      'entity reference then property specifier `entity:entity_test_with_bundle` then a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.entity:entity_test_with_bundle.field_test_text.value'],
+
+      'entity reference with a delta and no reference property then a complex field and property specifier `value`' => ['field_test_ref1.0.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.0.field_test_text.value'],
+      'entity reference with a delta and a reference property then a complex field and property specifier `value`' => ['field_test_ref1.0.entity.field_test_text.value', 'field_test_ref1.0.entity.field_test_text.value'],
+
+      'entity reference with no reference property then another entity reference with no reference property a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.field_test_ref3.field_test_text.value'],
+      'entity reference with a reference property then another entity reference with no reference property a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_ref3.entity:entity_test_with_bundle.field_test_text.value', 'field_test_ref1.entity.field_test_ref3.field_test_text.value'],
+      'entity reference with no reference property then another entity reference with a reference property a complex field with property specifier `value`' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity.field_test_text.value', 'field_test_ref1.field_test_ref3.entity.field_test_text.value'],
+      'entity reference with a reference property then another entity reference with a reference property a complex field with property specifier `value`' => ['field_test_ref1.entity.field_test_ref3.entity.field_test_text.value', 'field_test_ref1.entity.field_test_ref3.entity.field_test_text.value'],
+
+      'entity reference with target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on multiple bundles' => [
+        'field_test_ref1.entity:entity_test_with_bundle.field_test3',
+        'field_test_ref1.entity:entity_test_with_bundle.field_test3',
+      ],
+      'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on a single bundle' => [
+        'field_test_ref2.entity:entity_test_with_bundle.field_test1',
+        'field_test_ref2.entity:entity_test_with_bundle.field_test1',
+      ],
+      'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on multiple bundles' => [
+        'field_test_ref3.entity:entity_test_with_bundle.field_test3',
+        'field_test_ref3.entity:entity_test_with_bundle.field_test3',
+        'entity_test_with_bundle', 'bundle2',
+      ],
+      'entity reference without target bundles then property specifier `entity:entity_test_with_bundle` then a primitive field on a single bundle starting from a different resource type' => [
+        'field_test_ref3.entity:entity_test_with_bundle.field_test2',
+        'field_test_ref3.entity:entity_test_with_bundle.field_test2',
+        'entity_test_with_bundle', 'bundle3',
+      ],
+
+      'entity reference then property specifier `entity:entity_test_with_bundle` then another entity reference before a primitive field' => ['field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.entity:entity_test_with_bundle.field_test2', 'field_test_ref1.entity:entity_test_with_bundle.field_test_ref3.field_test2'],
+    ];
+  }
+
+  /**
+   * Expects an error when an invalid field is provided for filter and sort.
+   *
+   * @param string $entity_type
+   *   The entity type for which to test field resolution.
+   * @param string $bundle
+   *   The entity bundle for which to test field resolution.
+   * @param string $external_path
+   *   The external field path to resolve.
+   * @param string $expected_message
+   *   (optional) An expected exception message.
+   *
+   * @covers ::resolveInternalEntityQueryPath
+   * @dataProvider resolveInternalEntityQueryPathErrorProvider
+   */
+  public function testResolveInternalEntityQueryPathError($entity_type, $bundle, $external_path, $expected_message = '') {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, $expected_message);
+    $this->sut->resolveInternalEntityQueryPath($entity_type, $bundle, $external_path);
+  }
+
+  /**
+   * Provides test cases for ::testResolveInternalEntityQueryPathError.
+   */
+  public function resolveInternalEntityQueryPathErrorProvider() {
+    return [
+      'nested fields' => [
+        'entity_test_with_bundle', 'bundle1', 'none.of.these.exist',
+      ],
+      'field does not exist on bundle' => [
+        'entity_test_with_bundle', 'bundle2', 'field_test_ref2',
+      ],
+      'field does not exist on different bundle' => [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref3',
+      ],
+      'field does not exist on targeted bundle' => [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test1',
+      ],
+      'different field does not exist on same targeted bundle' => [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test2',
+      ],
+      'entity reference field does not exist on targeted bundle' => [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref1',
+      ],
+      'different entity reference field does not exist on same targeted bundle' => [
+        'entity_test_with_bundle', 'bundle1', 'field_test_ref1.field_test_ref2',
+      ],
+      'message correctly identifies missing field' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.entity:entity_test_with_bundle.field_test1',
+        'Invalid nested filtering. The field `field_test1`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test1`, does not exist.',
+      ],
+      'message correctly identifies different missing field' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.entity:entity_test_with_bundle.field_test2',
+        'Invalid nested filtering. The field `field_test2`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test2`, does not exist.',
+      ],
+      'message correctly identifies missing entity reference field' => [
+        'entity_test_with_bundle', 'bundle2',
+        'field_test_ref1.entity:entity_test_with_bundle.field_test2',
+        'Invalid nested filtering. The field `field_test_ref1`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test2`, does not exist.',
+      ],
+
+      'entity reference then a complex field with no property specifier' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref2.field_test_text',
+        'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref2.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
+      ],
+
+      'entity reference then no delta with property specifier `target_id`' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.target_id',
+        'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.target_id`, does not exist. Filter by `field_test_ref1`, not `field_test_ref1.target_id` (the JSON:API module elides property names from single-property fields).',
+      ],
+      'entity reference then delta 0 with property specifier `target_id`' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.0.target_id',
+        'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.0.target_id`, does not exist. Filter by `field_test_ref1.0`, not `field_test_ref1.0.target_id` (the JSON:API module elides property names from single-property fields).',
+      ],
+      'entity reference then delta 1 with property specifier `target_id`' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.1.target_id',
+        'Invalid nested filtering. The property `target_id`, given in the path `field_test_ref1.1.target_id`, does not exist. Filter by `field_test_ref1.1`, not `field_test_ref1.1.target_id` (the JSON:API module elides property names from single-property fields).',
+      ],
+
+      'entity reference then no reference property then a complex field' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.field_test_text',
+        'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
+
+      ],
+      'entity reference then reference property then a complex field' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.entity.field_test_text',
+        'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.entity.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
+      ],
+
+      'entity reference then property specifier `entity:entity_test_with_bundle` then a complex field' => [
+        'entity_test_with_bundle', 'bundle1',
+        'field_test_ref1.entity:entity_test_with_bundle.field_test_text',
+        'Invalid nested filtering. The field `field_test_text`, given in the path `field_test_ref1.entity:entity_test_with_bundle.field_test_text` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.',
+      ],
+    ];
+  }
+
+  /**
+   * Create a simple bundle.
+   *
+   * @param string $name
+   *   The name of the bundle to create.
+   */
+  protected function makeBundle($name) {
+    EntityTestBundle::create([
+      'id' => $name,
+    ])->save();
+  }
+
+  /**
+   * Creates a field for a specified entity type/bundle.
+   *
+   * @param string $type
+   *   The field type.
+   * @param string $name
+   *   The name of the field to create.
+   * @param string $entity_type
+   *   The entity type to which the field will be attached.
+   * @param string[] $bundles
+   *   The entity bundles to which the field will be attached.
+   * @param array $storage_settings
+   *   Custom storage settings for the field.
+   * @param array $config_settings
+   *   Custom configuration settings for the field.
+   */
+  protected function makeField($type, $name, $entity_type, array $bundles, array $storage_settings = [], array $config_settings = []) {
+    $storage_config = [
+      'field_name' => $name,
+      'type' => $type,
+      'entity_type' => $entity_type,
+      'settings' => $storage_settings,
+    ];
+
+    FieldStorageConfig::create($storage_config)->save();
+
+    foreach ($bundles as $bundle) {
+      FieldConfig::create([
+        'field_name' => $name,
+        'entity_type' => $entity_type,
+        'bundle' => $bundle,
+        'settings' => $config_settings,
+      ])->save();
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
new file mode 100644
index 0000000000..ae39d812f7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -0,0 +1,690 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Controller\EntityResource;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Drupal\user\RoleInterface;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Controller\EntityResource
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityResourceTest extends JsonapiKernelTestBase {
+
+  /**
+   * Static UUIDs to use in testing.
+   *
+   * @var array
+   */
+  protected static $nodeUuid = [
+    1 => '83bc47ad-2c58-45e3-9136-abcdef111111',
+    2 => '83bc47ad-2c58-45e3-9136-abcdef222222',
+    3 => '83bc47ad-2c58-45e3-9136-abcdef333333',
+    4 => '83bc47ad-2c58-45e3-9136-abcdef444444',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'field',
+    'jsonapi',
+    'serialization',
+    'system',
+    'user',
+  ];
+
+  /**
+   * The user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * The node.
+   *
+   * @var \Drupal\node\Entity\Node
+   */
+  protected $node;
+
+  /**
+   * The other node.
+   *
+   * @var \Drupal\node\Entity\Node
+   */
+  protected $node2;
+
+  /**
+   * An unpublished node.
+   *
+   * @var \Drupal\node\Entity\Node
+   */
+  protected $node3;
+
+  /**
+   * A fake request.
+   *
+   * @var \Symfony\Component\HttpFoundation\Request
+   */
+  protected $request;
+
+  /**
+   * The EntityResource under test.
+   *
+   * @var \Drupal\jsonapi\Controller\EntityResource
+   */
+  protected $entityResource;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    NodeType::create([
+      'type' => 'lorem',
+    ])->save();
+    $type = NodeType::create([
+      'type' => 'article',
+    ]);
+    $type->save();
+    $this->user = User::create([
+      'name' => 'user1',
+      'mail' => 'user@localhost',
+      'status' => 1,
+      'roles' => ['test_role_one', 'test_role_two'],
+    ]);
+    $this->createEntityReferenceField('node', 'article', 'field_relationships', 'Relationship', 'node', 'default', ['target_bundles' => ['article']], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    $this->user->save();
+
+    $this->node = Node::create([
+      'title' => 'dummy_title',
+      'type' => 'article',
+      'uid' => $this->user->id(),
+      'uuid' => static::$nodeUuid[1],
+    ]);
+    $this->node->save();
+
+    $this->node2 = Node::create([
+      'type' => 'article',
+      'title' => 'Another test node',
+      'uid' => $this->user->id(),
+      'uuid' => static::$nodeUuid[2],
+    ]);
+    $this->node2->save();
+
+    $this->node3 = Node::create([
+      'type' => 'article',
+      'title' => 'Unpublished test node',
+      'uid' => $this->user->id(),
+      'status' => 0,
+      'uuid' => static::$nodeUuid[3],
+    ]);
+    $this->node3->save();
+
+    $this->node4 = Node::create([
+      'type' => 'article',
+      'title' => 'Test node with related nodes',
+      'uid' => $this->user->id(),
+      'field_relationships' => [
+        ['target_id' => $this->node->id()],
+        ['target_id' => $this->node2->id()],
+        ['target_id' => $this->node3->id()],
+      ],
+      'uuid' => static::$nodeUuid[4],
+    ]);
+    $this->node4->save();
+
+    // Give anonymous users permission to view user profiles, so that we can
+    // verify the cache tags of cached versions of user profile pages.
+    array_map(function ($role_id) {
+      Role::create([
+        'id' => $role_id,
+        'permissions' => [
+          'access user profiles',
+          'access content',
+        ],
+      ])->save();
+    }, [RoleInterface::ANONYMOUS_ID, 'test_role_one', 'test_role_two']);
+
+    $this->entityResource = new EntityResource(
+      $this->container->get('entity_type.manager'),
+      $this->container->get('entity_field.manager'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository'),
+      $this->container->get('renderer'),
+      $this->container->get('entity.repository'),
+      $this->container->get('jsonapi.include_resolver'),
+      $this->container->get('jsonapi.entity_access_checker'),
+      $this->container->get('jsonapi.field_resolver')
+    );
+  }
+
+  /**
+   * @covers ::getIndividual
+   */
+  public function testGetIndividual() {
+    $response = $this->entityResource->getIndividual($this->node, Request::create('/jsonapi/node/article'));
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertEquals($this->node->uuid(), $response->getResponseData()->getData()->getId());
+  }
+
+  /**
+   * @covers ::getIndividual
+   */
+  public function testGetIndividualDenied() {
+    $role = Role::load(RoleInterface::ANONYMOUS_ID);
+    $role->revokePermission('access content');
+    $role->save();
+    $this->setExpectedException(EntityAccessDeniedHttpException::class);
+    $this->entityResource->getIndividual($this->node, Request::create('/jsonapi/node/article'));
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetCollection() {
+    $request = Request::create('/jsonapi/node/article');
+    $request->query = new ParameterBag(['sort' => 'nid']);
+
+    // Get the response.
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $response = $this->entityResource->getCollection($resource_type, $request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $this->assertEquals($this->node->uuid(), $response->getResponseData()->getData()->getIterator()->current()->getId());
+    $this->assertEquals([
+      'node:1',
+      'node:2',
+      'node:3',
+      'node:4',
+      'node_list',
+    ], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetFilteredCollection() {
+    $request = Request::create('/jsonapi/node/article');
+    $request->query = new ParameterBag(['filter' => ['type' => 'article']]);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('entity_type.manager'),
+      $this->container->get('entity_field.manager'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository'),
+      $this->container->get('renderer'),
+      $this->container->get('entity.repository'),
+      $this->container->get('jsonapi.include_resolver'),
+      $this->container->get('jsonapi.entity_access_checker'),
+      $this->container->get('jsonapi.field_resolver')
+    );
+
+    // Get the response.
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type');
+    $response = $entity_resource->getCollection($resource_type, $request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $this->assertCount(1, $response->getResponseData()->getData());
+    $expected_cache_tags = [
+      'config:node.type.article',
+      'config:node_type_list',
+    ];
+    $this->assertSame($expected_cache_tags, $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetSortedCollection() {
+    $request = Request::create('/jsonapi/node/article');
+    $request->query = new ParameterBag(['sort' => '-type']);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('entity_type.manager'),
+      $this->container->get('entity_field.manager'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository'),
+      $this->container->get('renderer'),
+      $this->container->get('entity.repository'),
+      $this->container->get('jsonapi.include_resolver'),
+      $this->container->get('jsonapi.entity_access_checker'),
+      $this->container->get('jsonapi.field_resolver')
+    );
+
+    // Get the response.
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type');
+    $response = $entity_resource->getCollection($resource_type, $request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $this->assertCount(2, $response->getResponseData()->getData());
+    // `drupal_internal__type` is the alias for a node_type entity's ID field.
+    $this->assertEquals($response->getResponseData()->getData()->toArray()[0]->getField('drupal_internal__type'), 'lorem');
+    $expected_cache_tags = [
+      'config:node.type.article',
+      'config:node.type.lorem',
+      'config:node_type_list',
+    ];
+    $this->assertSame($expected_cache_tags, $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetPagedCollection() {
+    $request = Request::create('/jsonapi/node/article');
+    $request->query = new ParameterBag([
+      'sort' => 'nid',
+      'page' => [
+        'offset' => 1,
+        'limit' => 1,
+      ],
+    ]);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('entity_type.manager'),
+      $this->container->get('entity_field.manager'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository'),
+      $this->container->get('renderer'),
+      $this->container->get('entity.repository'),
+      $this->container->get('jsonapi.include_resolver'),
+      $this->container->get('jsonapi.entity_access_checker'),
+      $this->container->get('jsonapi.field_resolver')
+    );
+
+    // Get the response.
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->get('node', 'article');
+    $response = $entity_resource->getCollection($resource_type, $request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $data = $response->getResponseData()->getData();
+    $this->assertCount(1, $data);
+    $this->assertEquals($this->node2->uuid(), $data->toArray()[0]->getId());
+    $this->assertEquals(['node:2', 'node_list'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetEmptyCollection() {
+    $request = Request::create('/jsonapi/node/article');
+    $request->query = new ParameterBag(['filter' => ['id' => 'invalid']]);
+
+    // Get the response.
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $response = $this->entityResource->getCollection($resource_type, $request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $this->assertEquals(0, $response->getResponseData()->getData()->count());
+    $this->assertEquals(['node_list'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getRelated
+   */
+  public function testGetRelated() {
+    // to-one relationship.
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $resource_type->setRelatableResourceTypes([
+      'uid' => [new ResourceType('user', 'user', NULL)],
+      'roles' => [new ResourceType('user_role', 'user_role', NULL)],
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $response = $this->entityResource->getRelated($resource_type, $this->node, 'uid', Request::create('/jsonapi/node/article/' . $this->node->uuid(), '/uid'));
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(ResourceObject::class, $response->getResponseData()->getData()->toArray()[0]);
+    $this->assertEquals($this->user->uuid(), $response->getResponseData()->getData()->toArray()[0]->getId());
+    $this->assertEquals(['node:1'], $response->getCacheableMetadata()->getCacheTags());
+    // to-many relationship.
+    $response = $this->entityResource->getRelated($resource_type, $this->node4, 'field_relationships', Request::create('/jsonapi/node/article/' . $this->node4->uuid(), '/field_relationships'));
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response
+      ->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response
+      ->getResponseData()
+      ->getData());
+    $this->assertEquals(['node:4'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getRelationship
+   */
+  public function testGetRelationship() {
+    // to-one relationship.
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $resource_type->setRelatableResourceTypes([
+      'uid' => [new ResourceType('user', 'user', NULL)],
+    ]);
+    $response = $this->entityResource->getRelationship($resource_type, $this->node, 'uid', Request::create('/jsonapi/node/article/' . $this->node->uuid() . '/relationships/uid'));
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(
+      EntityReferenceFieldItemListInterface::class,
+      $response->getResponseData()->getData()
+    );
+    $this->assertEquals(1, $response
+      ->getResponseData()
+      ->getData()
+      ->getEntity()
+      ->id()
+    );
+    $this->assertEquals('node', $response
+      ->getResponseData()
+      ->getData()
+      ->getEntity()
+      ->getEntityTypeId()
+    );
+  }
+
+  /**
+   * @covers ::createIndividual
+   */
+  public function testCreateIndividual() {
+    $node = Node::create([
+      'type' => 'article',
+      'title' => 'Lorem ipsum',
+    ]);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('create article content')
+      ->save();
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $response = $this->entityResource->createIndividual($resource_type, $node, Request::create('/jsonapi/node/article'));
+    // As a side effect, the node will also be saved.
+    $this->assertNotEmpty($node->id());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertEquals($node->uuid(), $response->getResponseData()->getData()->getId());
+    $this->assertEquals(201, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::createIndividual
+   */
+  public function testCreateIndividualWithMissingRequiredData() {
+    $node = Node::create([
+      'type' => 'article',
+      // No title specified, even if its required.
+    ]);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('create article content')
+      ->save();
+    $this->setExpectedException(HttpException::class, 'Unprocessable Entity: validation failed.');
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $this->entityResource->createIndividual($resource_type, $node, Request::create('/jsonapi/node/article'));
+  }
+
+  /**
+   * @covers ::createIndividual
+   */
+  public function testCreateIndividualDuplicateError() {
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('create article content')
+      ->save();
+
+    $node = Node::create([
+      'type' => 'article',
+      'title' => 'Lorem ipsum',
+    ]);
+    $node->save();
+    $node->enforceIsNew();
+
+    $this->setExpectedException(ConflictHttpException::class, 'Conflict: Entity already exists.');
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $this->entityResource->createIndividual($resource_type, $node, Request::create('/jsonapi/node/article'));
+  }
+
+  /**
+   * @covers ::patchIndividual
+   * @dataProvider patchIndividualProvider
+   */
+  public function testPatchIndividual($values) {
+    $parsed_node = Node::create($values);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+    $payload = Json::encode([
+      'data' => [
+        'type' => 'article',
+        'id' => $this->node->uuid(),
+        'attributes' => [
+          'title' => '',
+          'field_relationships' => '',
+        ],
+      ],
+    ]);
+    $request = Request::create('/jsonapi/node/article/' . $this->node->uuid(), 'PATCH', [], [], [], [], $payload);
+
+    // Create a new EntityResource that uses uuid.
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $response = $this->entityResource->patchIndividual($resource_type, $this->node, $parsed_node, $request);
+
+    // As a side effect, the node will also be saved.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $updated_node = $response->getResponseData()->getData();
+    $this->assertInstanceOf(ResourceObject::class, $updated_node);
+    $this->assertSame($values['title'], $this->node->getTitle());
+    $this->assertSame($values['field_relationships'], $this->node->get('field_relationships')->getValue());
+    $this->assertEquals(200, $response->getStatusCode());
+  }
+
+  /**
+   * Provides data for the testPatchIndividual.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function patchIndividualProvider() {
+    return [
+      [
+        [
+          'type' => 'article',
+          'title' => 'PATCHED',
+          'field_relationships' => [['target_id' => 1]],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::deleteIndividual
+   */
+  public function testDeleteIndividual() {
+    $node = Node::create([
+      'type' => 'article',
+      'title' => 'Lorem ipsum',
+    ]);
+    $nid = $node->id();
+    $node->save();
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('delete own article content')
+      ->save();
+    $response = $this->entityResource->deleteIndividual($node);
+    // As a side effect, the node will also be deleted.
+    $count = $this->container->get('entity_type.manager')
+      ->getStorage('node')
+      ->getQuery()
+      ->condition('nid', $nid)
+      ->count()
+      ->execute();
+    $this->assertEquals(0, $count);
+    $this->assertNull($response->getResponseData());
+    $this->assertEquals(204, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::addToRelationshipData
+   */
+  public function testAddToRelationshipData() {
+    $resource_identifiers = [new ResourceIdentifier('node--article', $this->node->uuid())];
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $resource_type->setRelatableResourceTypes([
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $request = Request::create('/jsonapi/node/article/' . $this->node->uuid() . '/relationships/field_relationships', 'POST');
+    $response = $this->entityResource->addToRelationshipData($resource_type, $this->node, 'field_relationships', $resource_identifiers, $request);
+
+    // As a side effect, the node will also be saved.
+    $this->assertNotEmpty($this->node->id());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $field_list = $response->getResponseData()->getData();
+    $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list);
+    $this->assertSame('field_relationships', $field_list->getName());
+    $this->assertEquals([['target_id' => 1]], $field_list->getValue());
+    $this->assertEquals(204, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::replaceRelationshipData
+   * @dataProvider replaceRelationshipDataProvider
+   */
+  public function testReplaceRelationshipData($relationships) {
+    $this->node->field_relationships->appendItem(['target_id' => $this->node->id()]);
+    $this->node->save();
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $resource_type->setRelatableResourceTypes([
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $request = Request::create('/jsonapi/node/article/' . $this->node->uuid() . '/relationships/field_relationships', 'PATCH');
+    $response = $this->entityResource->replaceRelationshipData($resource_type, $this->node, 'field_relationships', $relationships, $request);
+
+    // As a side effect, the node will also be saved.
+    $this->assertNotEmpty($this->node->id());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $field_list = $response->getResponseData()->getData();
+    $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list);
+    $this->assertSame('field_relationships', $field_list->getName());
+    $this->assertEquals(
+      array_map(function (ResourceIdentifier $identifier) {
+        return $identifier->getId();
+      }, $relationships),
+      array_map(function (EntityInterface $entity) {
+        return $entity->uuid();
+      }, $field_list->referencedEntities())
+    );
+    $this->assertEquals(204, $response->getStatusCode());
+  }
+
+  /**
+   * Provides data for the testPatchRelationship.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function replaceRelationshipDataProvider() {
+    return [
+      // Replace relationships.
+      [
+        [
+          new ResourceIdentifier('node--article', static::$nodeUuid[1]),
+          new ResourceIdentifier('node--article', static::$nodeUuid[2]),
+        ],
+      ],
+      // Remove relationships.
+      [[]],
+    ];
+  }
+
+  /**
+   * @covers ::removeFromRelationshipData
+   * @dataProvider removeFromRelationshipDataProvider
+   */
+  public function testRemoveFromRelationshipData($deleted_rels, $kept_rels) {
+    $this->node->field_relationships->appendItem(['target_id' => $this->node->id()]);
+    $this->node->field_relationships->appendItem(['target_id' => $this->node2->id()]);
+    $this->node->save();
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $resource_type = new ResourceType('node', 'article', NULL);
+    $resource_type->setRelatableResourceTypes([
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $request = Request::create('/jsonapi/node/article/' . $this->node->uuid() . '/relationships/field_relationships', 'DELETE');
+    $response = $this->entityResource->removeFromRelationshipData($resource_type, $this->node, 'field_relationships', $deleted_rels, $request);
+
+    // As a side effect, the node will also be saved.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $field_list = $response->getResponseData()->getData();
+    $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list);
+    $this->assertSame('field_relationships', $field_list->getName());
+    $this->assertEquals($kept_rels, $field_list->getValue());
+    $this->assertEquals(204, $response->getStatusCode());
+  }
+
+  /**
+   * Provides data for the testDeleteRelationship.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function removeFromRelationshipDataProvider() {
+    return [
+      // Remove one relationship.
+      [
+        [
+          new ResourceIdentifier('node--article', static::$nodeUuid[1]),
+        ],
+        [['target_id' => 2]],
+      ],
+      // Remove all relationships.
+      [
+        [
+          new ResourceIdentifier('node--article', static::$nodeUuid[2]),
+          new ResourceIdentifier('node--article', static::$nodeUuid[1]),
+        ],
+        [],
+      ],
+      // Remove no relationship.
+      [[], [['target_id' => 1], ['target_id' => 2]]],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php b/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php
new file mode 100644
index 0000000000..e53c43dc03
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel;
+
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Contains shared test utility methods.
+ *
+ * @internal
+ */
+abstract class JsonapiKernelTestBase extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['jsonapi'];
+
+  /**
+   * Creates a field of an entity reference field storage on the bundle.
+   *
+   * @param string $entity_type
+   *   The type of entity the field will be attached to.
+   * @param string $bundle
+   *   The bundle name of the entity the field will be attached to.
+   * @param string $field_name
+   *   The name of the field; if it exists, a new instance of the existing.
+   *   field will be created.
+   * @param string $field_label
+   *   The label of the field.
+   * @param string $target_entity_type
+   *   The type of the referenced entity.
+   * @param string $selection_handler
+   *   The selection handler used by this field.
+   * @param array $handler_settings
+   *   An array of settings supported by the selection handler specified above.
+   *   (e.g. 'target_bundles', 'sort', 'auto_create', etc).
+   * @param int $cardinality
+   *   The cardinality of the field.
+   *
+   * @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm()
+   */
+  protected function createEntityReferenceField($entity_type, $bundle, $field_name, $field_label, $target_entity_type, $selection_handler = 'default', array $handler_settings = [], $cardinality = 1) {
+    // Look for or add the specified field to the requested entity bundle.
+    if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
+      FieldStorageConfig::create([
+        'field_name' => $field_name,
+        'type' => 'entity_reference',
+        'entity_type' => $entity_type,
+        'cardinality' => $cardinality,
+        'settings' => [
+          'target_type' => $target_entity_type,
+        ],
+      ])->save();
+    }
+    if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
+      FieldConfig::create([
+        'field_name' => $field_name,
+        'entity_type' => $entity_type,
+        'bundle' => $bundle,
+        'label' => $field_label,
+        'settings' => [
+          'handler' => $selection_handler,
+          'handler_settings' => $handler_settings,
+        ],
+      ])->save();
+    }
+  }
+
+  /**
+   * Creates a field of an entity reference field storage on the bundle.
+   *
+   * @param string $entity_type
+   *   The type of entity the field will be attached to.
+   * @param string $bundle
+   *   The bundle name of the entity the field will be attached to.
+   * @param string $field_name
+   *   The name of the field; if it exists, a new instance of the existing.
+   *   field will be created.
+   * @param string $field_label
+   *   The label of the field.
+   * @param int $cardinality
+   *   The cardinality of the field.
+   *
+   * @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm()
+   */
+  protected function createTextField($entity_type, $bundle, $field_name, $field_label, $cardinality = 1) {
+    // Look for or add the specified field to the requested entity bundle.
+    if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
+      FieldStorageConfig::create([
+        'field_name' => $field_name,
+        'type' => 'text',
+        'entity_type' => $entity_type,
+        'cardinality' => $cardinality,
+      ])->save();
+    }
+    if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
+      FieldConfig::create([
+        'field_name' => $field_name,
+        'entity_type' => $entity_type,
+        'bundle' => $bundle,
+        'label' => $field_label,
+      ])->save();
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php
new file mode 100644
index 0000000000..131e1122ae
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityReferenceFieldNormalizerTest.php
@@ -0,0 +1,328 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\file\Entity\File;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\user\Entity\User;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class EntityReferenceFieldNormalizerTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    'file',
+    'image',
+    'jsonapi',
+    'node',
+    'serialization',
+    'system',
+    'user',
+  ];
+
+  /**
+   * Static UUID for the referencing entity.
+   *
+   * @var string
+   */
+  protected static $referencerId = '2c344ae5-4303-4f17-acd4-e20d2a9a6c44';
+
+  /**
+   * Static UUIDs for use in tests.
+   *
+   * @var string[]
+   */
+  protected static $userIds = [
+    '457fed75-a3ed-4e9e-823c-f9aeff6ec8ca',
+    '67e4063f-ac74-46ac-ac5f-07efda9fd551',
+  ];
+
+  /**
+   * Static UUIDs for use in tests.
+   *
+   * @var string[]
+   */
+  protected static $imageIds = [
+    '71e67249-df4a-4616-9065-4cc2e812235b',
+    'ce5093fc-417f-477d-932d-888407d5cbd5',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Set up the data model.
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('file');
+
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('file', ['file_usage']);
+    NodeType::create([
+      'type' => 'referencer',
+    ])->save();
+    $this->createEntityReferenceField('node', 'referencer', 'field_user', 'User', 'user', 'default', ['target_bundles' => NULL], 1);
+    $this->createEntityReferenceField('node', 'referencer', 'field_users', 'Users', 'user', 'default', ['target_bundles' => NULL], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    $field_storage_config = [
+      'type' => 'image',
+      'entity_type' => 'node',
+    ];
+    FieldStorageConfig::create(['field_name' => 'field_image', 'cardinality' => 1] + $field_storage_config)->save();
+    FieldStorageConfig::create(['field_name' => 'field_images', 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED] + $field_storage_config)->save();
+    $field_config = [
+      'entity_type' => 'node',
+      'bundle' => 'referencer',
+    ];
+    FieldConfig::create(['field_name' => 'field_image', 'label' => 'Image'] + $field_config)->save();
+    FieldConfig::create(['field_name' => 'field_images', 'label' => 'Images'] + $field_config)->save();
+
+    // Set up the test data.
+    $this->account = $this->prophesize(AccountInterface::class)->reveal();
+    $this->user1 = User::create([
+      'name' => $this->randomMachineName(),
+      'mail' => $this->randomMachineName() . '@example.com',
+      'uuid' => static::$userIds[0],
+    ]);
+    $this->user1->save();
+    $this->user2 = User::create([
+      'name' => $this->randomMachineName(),
+      'mail' => $this->randomMachineName() . '@example.com',
+      'uuid' => static::$userIds[1],
+    ]);
+    $this->user2->save();
+
+    $this->image1 = File::create([
+      'uri' => 'public:/image1.png',
+      'uuid' => static::$imageIds[0],
+    ]);
+    $this->image1->save();
+    $this->image2 = File::create([
+      'uri' => 'public:/image2.png',
+      'uuid' => static::$imageIds[1],
+    ]);
+    $this->image2->save();
+
+    // Create the node from which all the previously created entities will be
+    // referenced.
+    $this->referencer = Node::create([
+      'title' => 'Referencing node',
+      'type' => 'referencer',
+      'status' => 1,
+      'uuid' => static::$referencerId,
+    ]);
+    $this->referencer->save();
+
+    // Set up the test dependencies.
+    $this->referencingResourceType = $this->container->get('jsonapi.resource_type.repository')->get('node', 'referencer');
+    $this->normalizer = new EntityReferenceFieldNormalizer(
+      $this->container->get('jsonapi.link_manager')
+    );
+    $this->normalizer->setSerializer($this->container->get('jsonapi.serializer'));
+  }
+
+  /**
+   * @covers ::normalize
+   * @dataProvider normalizeProvider
+   */
+  public function testNormalize($entity_property_names, $field_name, $expected) {
+    // Links cannot be generated in the test provider because the container
+    // has not yet been set.
+    $expected['links'] = [
+      'self' => ['href' => Url::fromUri('base:/jsonapi/node/referencer/' . static::$referencerId . "/relationships/$field_name")->setAbsolute()->toString()],
+      'related' => ['href' => Url::fromUri('base:/jsonapi/node/referencer/' . static::$referencerId . "/$field_name")->setAbsolute()->toString()],
+    ];
+    // Set up different field values.
+    $this->referencer->{$field_name} = array_map(function ($entity_property_name) {
+      $value = ['target_id' => $this->{$entity_property_name === 'image1a' ? 'image1' : $entity_property_name}->id()];
+      switch ($entity_property_name) {
+        case 'image1':
+          $value['alt'] = 'Cute llama';
+          $value['title'] = 'My spirit animal';
+          break;
+
+        case 'image1a':
+          $value['alt'] = 'Ugly llama';
+          $value['title'] = 'My alter ego';
+          break;
+
+        case 'image2':
+          $value['alt'] = 'Adorable llama';
+          $value['title'] = 'My spirit animal 😍';
+          break;
+      }
+      return $value;
+    }, $entity_property_names);
+    // Normalize.
+    $actual = $this->normalizer->normalize($this->referencer->{$field_name}, 'api_json', [
+      'account' => $this->account,
+      'resource_object' => new ResourceObject($this->referencingResourceType, $this->referencer),
+    ]);
+    // Assert.
+    assert($actual instanceof CacheableNormalization);
+    $this->assertEquals($expected, $actual->getNormalization());
+  }
+
+  /**
+   * Data provider for testNormalize.
+   */
+  public function normalizeProvider() {
+    return [
+      'single cardinality' => [
+        ['user1'],
+        'field_user',
+        [
+          'data' => ['type' => 'user--user', 'id' => static::$userIds[0]],
+        ],
+      ],
+      'multiple cardinality' => [
+        ['user1', 'user2'], 'field_users', [
+          'data' => [
+            ['type' => 'user--user', 'id' => static::$userIds[0]],
+            ['type' => 'user--user', 'id' => static::$userIds[1]],
+          ],
+        ],
+      ],
+      'multiple cardinality, all same values' => [
+        ['user1', 'user1'], 'field_users', [
+          'data' => [
+            [
+              'type' => 'user--user',
+              'id' => static::$userIds[0],
+              'meta' => ['arity' => 0],
+            ],
+            [
+              'type' => 'user--user',
+              'id' => static::$userIds[0],
+              'meta' => ['arity' => 1],
+            ],
+          ],
+        ],
+      ],
+      'multiple cardinality, some same values' => [
+        ['user1', 'user2', 'user1'], 'field_users', [
+          'data' => [
+            [
+              'type' => 'user--user',
+              'id' => static::$userIds[0],
+              'meta' => ['arity' => 0],
+            ],
+            [
+              'type' => 'user--user',
+              'id' => static::$userIds[1],
+            ],
+            [
+              'type' => 'user--user',
+              'id' => static::$userIds[0],
+              'meta' => ['arity' => 1],
+            ],
+          ],
+        ],
+      ],
+      'single cardinality, with meta' => [
+        ['image1'], 'field_image', [
+          'data' => [
+            'type' => 'file--file',
+            'id' => static::$imageIds[0],
+            'meta' => [
+              'alt' => 'Cute llama',
+              'title' => 'My spirit animal',
+              'width' => NULL,
+              'height' => NULL,
+            ],
+          ],
+        ],
+      ],
+      'multiple cardinality, all same values, with meta' => [
+        ['image1', 'image1'], 'field_images', [
+          'data' => [
+            [
+              'type' => 'file--file',
+              'id' => static::$imageIds[0],
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'width' => NULL,
+                'height' => NULL,
+                'arity' => 0,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => static::$imageIds[0],
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'width' => NULL,
+                'height' => NULL,
+                'arity' => 1,
+              ],
+            ],
+          ],
+        ],
+      ],
+      'multiple cardinality, some same values with same values but different meta' => [
+        ['image1', 'image1', 'image1a'], 'field_images', [
+          'data' => [
+            [
+              'type' => 'file--file',
+              'id' => static::$imageIds[0],
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'width' => NULL,
+                'height' => NULL,
+                'arity' => 0,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => static::$imageIds[0],
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'width' => NULL,
+                'height' => NULL,
+                'arity' => 1,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => static::$imageIds[0],
+              'meta' => [
+                'alt' => 'Ugly llama',
+                'title' => 'My alter ego',
+                'width' => NULL,
+                'height' => NULL,
+                'arity' => 2,
+              ],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
new file mode 100644
index 0000000000..9b0c39ddfa
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,765 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\file\Entity\File;
+use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\JsonApiResource\LinkCollection;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Drupal\user\RoleInterface;
+use Prophecy\Argument;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
+
+  use ImageFieldCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'jsonapi',
+    'field',
+    'node',
+    'serialization',
+    'system',
+    'taxonomy',
+    'text',
+    'filter',
+    'user',
+    'file',
+    'image',
+    'jsonapi_test_normalizers_kernel',
+  ];
+
+  /**
+   * A node to normalize.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $node;
+
+  /**
+   * A user to normalize.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * The include resolver.
+   *
+   * @var \Drupal\jsonapi\IncludeResolver
+   */
+  protected $includeResolver;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installEntitySchema('file');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    $this->installSchema('file', ['file_usage']);
+    $type = NodeType::create([
+      'type' => 'article',
+    ]);
+    $type->save();
+    $this->createEntityReferenceField(
+      'node',
+      'article',
+      'field_tags',
+      'Tags',
+      'taxonomy_term',
+      'default',
+      ['target_bundles' => ['tags']],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->createTextField('node', 'article', 'body', 'Body');
+
+    $this->createImageField('field_image', 'article');
+
+    $this->user = User::create([
+      'name' => 'user1',
+      'mail' => 'user@localhost',
+    ]);
+    $this->user2 = User::create([
+      'name' => 'user2',
+      'mail' => 'user2@localhost',
+    ]);
+
+    $this->user->save();
+    $this->user2->save();
+
+    $this->vocabulary = Vocabulary::create(['name' => 'Tags', 'vid' => 'tags']);
+    $this->vocabulary->save();
+
+    $this->term1 = Term::create([
+      'name' => 'term1',
+      'vid' => $this->vocabulary->id(),
+    ]);
+    $this->term2 = Term::create([
+      'name' => 'term2',
+      'vid' => $this->vocabulary->id(),
+    ]);
+
+    $this->term1->save();
+    $this->term2->save();
+
+    $this->file = File::create([
+      'uri' => 'public://example.png',
+      'filename' => 'example.png',
+    ]);
+    $this->file->save();
+
+    $this->node = Node::create([
+      'title' => 'dummy_title',
+      'type' => 'article',
+      'uid' => 1,
+      'body' => [
+        'format' => 'plain_text',
+        'value' => $this->randomStringValidate(42),
+      ],
+      'field_tags' => [
+        ['target_id' => $this->term1->id()],
+        ['target_id' => $this->term2->id()],
+      ],
+      'field_image' => [
+        [
+          'target_id' => $this->file->id(),
+          'alt' => 'test alt',
+          'title' => 'test title',
+          'width' => 10,
+          'height' => 11,
+        ],
+      ],
+    ]);
+
+    $this->node->save();
+
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $this->container->set('jsonapi.link_manager', $link_manager->reveal());
+
+    $this->nodeType = NodeType::load('article');
+
+    Role::create([
+      'id' => RoleInterface::ANONYMOUS_ID,
+      'permissions' => [
+        'access content',
+      ],
+    ])->save();
+
+    $this->includeResolver = $this->container->get('jsonapi.include_resolver');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function tearDown() {
+    if ($this->node) {
+      $this->node->delete();
+    }
+    if ($this->term1) {
+      $this->term1->delete();
+    }
+    if ($this->term2) {
+      $this->term2->delete();
+    }
+    if ($this->vocabulary) {
+      $this->vocabulary->delete();
+    }
+    if ($this->user) {
+      $this->user->delete();
+    }
+    if ($this->user2) {
+      $this->user2->delete();
+    }
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalize() {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+
+    $resource_object = new ResourceObject($resource_type, $this->node);
+    $includes = $this->includeResolver->resolve($resource_object, 'uid,field_tags,field_image');
+
+    $jsonapi_doc_object = $this
+      ->getNormalizer()
+      ->normalize(
+        new JsonApiDocumentTopLevel($resource_object, $includes, new LinkCollection([])),
+        'api_json',
+        [
+          'resource_type' => $resource_type,
+          'account' => NULL,
+          'sparse_fieldset' => [
+            'node--article' => [
+              'title',
+              'node_type',
+              'uid',
+              'field_tags',
+              'field_image',
+            ],
+            'user--user' => [
+              'name',
+            ],
+          ],
+          'include' => [
+            'uid',
+            'field_tags',
+            'field_image',
+          ],
+        ]
+      );
+    $normalized = $jsonapi_doc_object->getNormalization();
+
+    // @see http://jsonapi.org/format/#document-jsonapi-object
+    $this->assertEquals($normalized['jsonapi']['version'], '1.0');
+    $this->assertEquals($normalized['jsonapi']['meta']['links']['self']['href'], 'http://jsonapi.org/format/1.0/');
+
+    $this->assertSame($normalized['data']['attributes']['title'], 'dummy_title');
+    $this->assertEquals($normalized['data']['id'], $this->node->uuid());
+    $this->assertSame([
+      'data' => [
+        'type' => 'node_type--node_type',
+        'id' => NodeType::load('article')->uuid(),
+      ],
+      'links' => [
+        'self' => ['href' => 'dummy_entity_link'],
+        'related' => ['href' => 'dummy_entity_link'],
+      ],
+    ], $normalized['data']['relationships']['node_type']);
+    $this->assertTrue(!isset($normalized['data']['attributes']['created']));
+    $this->assertEquals([
+      'alt' => 'test alt',
+      'title' => 'test title',
+      'width' => 10,
+      'height' => 11,
+    ], $normalized['data']['relationships']['field_image']['data']['meta']);
+    $this->assertSame('node--article', $normalized['data']['type']);
+    $this->assertEquals([
+      'data' => [
+        'type' => 'user--user',
+        'id' => $this->user->uuid(),
+      ],
+      'links' => [
+        'self' => ['href' => 'dummy_entity_link'],
+        'related' => ['href' => 'dummy_entity_link'],
+      ],
+    ], $normalized['data']['relationships']['uid']);
+    $this->assertTrue(empty($normalized['meta']['omitted']));
+    $this->assertSame($this->user->uuid(), $normalized['included'][0]['id']);
+    $this->assertSame('user--user', $normalized['included'][0]['type']);
+    $this->assertSame('user1', $normalized['included'][0]['attributes']['name']);
+    $this->assertCount(1, $normalized['included'][0]['attributes']);
+    $this->assertSame($this->term1->uuid(), $normalized['included'][1]['id']);
+    $this->assertSame('taxonomy_term--tags', $normalized['included'][1]['type']);
+    $this->assertSame($this->term1->label(), $normalized['included'][1]['attributes']['name']);
+    $this->assertCount(8, $normalized['included'][1]['attributes']);
+    $this->assertTrue(!isset($normalized['included'][1]['attributes']['created']));
+    // Make sure that the cache tags for the includes and the requested entities
+    // are bubbling as expected.
+    $this->assertArraySubset(
+      ['file:1', 'node:1', 'taxonomy_term:1', 'taxonomy_term:2', 'user:1'],
+      $jsonapi_doc_object->getCacheTags()
+    );
+    $this->assertSame(
+      Cache::PERMANENT,
+      $jsonapi_doc_object->getCacheMaxAge()
+    );
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeRelated() {
+    $this->markTestIncomplete('This fails and should be fixed by https://www.drupal.org/project/jsonapi/issues/2922121');
+
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uid');
+    $request->query = new ParameterBag([
+      'fields' => [
+        'user--user' => 'name,roles',
+      ],
+      'include' => 'roles',
+    ]);
+    $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class);
+    $author = $this->node->get('uid')->entity;
+    $document_wrapper->getData()->willReturn($author);
+
+    $jsonapi_doc_object = $this
+      ->getNormalizer()
+      ->normalize(
+        $document_wrapper->reveal(),
+        'api_json',
+        [
+          'resource_type' => $resource_type,
+          'account' => NULL,
+        ]
+      );
+    $normalized = $jsonapi_doc_object->getNormalization();
+    $this->assertSame($normalized['data']['attributes']['name'], 'user1');
+    $this->assertEquals($normalized['data']['id'], User::load(1)->uuid());
+    $this->assertEquals($normalized['data']['type'], 'user--user');
+    // Make sure that the cache tags for the includes and the requested entities
+    // are bubbling as expected.
+    $this->assertSame(['user:1'], $jsonapi_doc_object->getCacheTags());
+    $this->assertSame(Cache::PERMANENT, $jsonapi_doc_object->getCacheMaxAge());
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeUuid() {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uuid');
+    $resource_object = new ResourceObject($resource_type, $this->node);
+    $include_param = 'uid,field_tags';
+    $includes = $this->includeResolver->resolve($resource_object, $include_param);
+    $document_wrapper = new JsonApiDocumentTopLevel($resource_object, $includes, new LinkCollection([]));
+
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node--article' => 'title,node_type,uid,field_tags',
+        'user--user' => 'name',
+      ],
+      'include' => $include_param,
+    ]);
+
+    $jsonapi_doc_object = $this
+      ->getNormalizer()
+      ->normalize(
+        $document_wrapper,
+        'api_json',
+        [
+          'resource_type' => $resource_type,
+          'account' => NULL,
+          'include' => [
+            'uid',
+            'field_tags',
+          ],
+        ]
+      );
+    $normalized = $jsonapi_doc_object->getNormalization();
+    $this->assertStringMatchesFormat($this->node->uuid(), $normalized['data']['id']);
+    $this->assertEquals($this->node->type->entity->uuid(), $normalized['data']['relationships']['node_type']['data']['id']);
+    $this->assertEquals($this->user->uuid(), $normalized['data']['relationships']['uid']['data']['id']);
+    $this->assertFalse(empty($normalized['included'][0]['id']));
+    $this->assertTrue(empty($normalized['meta']['omitted']));
+    $this->assertEquals($this->user->uuid(), $normalized['included'][0]['id']);
+    $this->assertCount(1, $normalized['included'][0]['attributes']);
+    $this->assertCount(8, $normalized['included'][1]['attributes']);
+    // Make sure that the cache tags for the includes and the requested entities
+    // are bubbling as expected.
+    $this->assertArraySubset(
+      ['node:1', 'taxonomy_term:1', 'taxonomy_term:2', 'user:1'],
+      $jsonapi_doc_object->getCacheTags()
+    );
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeException() {
+    $normalized = $this
+      ->container
+      ->get('jsonapi.serializer')
+      ->normalize(
+        new JsonApiDocumentTopLevel(new ErrorCollection([new BadRequestHttpException('Lorem')]), new NullEntityCollection(), new LinkCollection([])),
+        'api_json',
+        []
+      )->getNormalization();
+    $this->assertNotEmpty($normalized['errors']);
+    $this->assertArrayNotHasKey('data', $normalized);
+    $this->assertEquals(400, $normalized['errors'][0]['status']);
+    $this->assertEquals('Lorem', $normalized['errors'][0]['detail']);
+    $this->assertEquals([
+      'info' => [
+        'href' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1',
+      ],
+      'via' => ['href' => 'http://localhost/'],
+    ], $normalized['errors'][0]['links']);
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeConfig() {
+    list($request, $resource_type) = $this->generateProphecies('node_type', 'node_type', 'id');
+    $resource_object = new ResourceObject($resource_type, $this->nodeType);
+    $document_wrapper = new JsonApiDocumentTopLevel($resource_object, new NullEntityCollection(), new LinkCollection([]));
+
+    $jsonapi_doc_object = $this
+      ->getNormalizer()
+      ->normalize($document_wrapper, 'api_json', [
+        'resource_type' => $resource_type,
+        'account' => NULL,
+        'sparse_fieldset' => [
+          'node_type--node_type' => [
+            'description',
+            'display_submitted',
+          ],
+        ],
+      ]);
+    $normalized = $jsonapi_doc_object->getNormalization();
+    $this->assertSame(['description', 'display_submitted'], array_keys($normalized['data']['attributes']));
+    $this->assertSame($normalized['data']['id'], NodeType::load('article')->uuid());
+    $this->assertSame($normalized['data']['type'], 'node_type--node_type');
+    // Make sure that the cache tags for the includes and the requested entities
+    // are bubbling as expected.
+    $this->assertSame(['config:node.type.article'], $jsonapi_doc_object->getCacheTags());
+  }
+
+  /**
+   * Try to POST a node and check if it exists afterwards.
+   *
+   * @covers ::denormalize
+   */
+  public function testDenormalize() {
+    $payload = '{"data":{"type":"article","attributes":{"title":"Testing article"}}}';
+
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id');
+    $node = $this
+      ->getNormalizer()
+      ->denormalize(Json::decode($payload), NULL, 'api_json', [
+        'resource_type' => $resource_type,
+      ]);
+    $this->assertInstanceOf(Node::class, $node);
+    $this->assertSame('Testing article', $node->getTitle());
+  }
+
+  /**
+   * Try to POST a node and check if it exists afterwards.
+   *
+   * @covers ::denormalize
+   */
+  public function testDenormalizeUuid() {
+    $configurations = [
+      // Good data.
+      [
+        [
+          [$this->term2->uuid(), $this->term1->uuid()],
+          $this->user2->uuid(),
+        ],
+        [
+          [$this->term2->id(), $this->term1->id()],
+          $this->user2->id(),
+        ],
+      ],
+      // Good data, without any tags.
+      [
+        [
+          [],
+          $this->user2->uuid(),
+        ],
+        [
+          [],
+          $this->user2->id(),
+        ],
+      ],
+      // Bad data in first tag.
+      [
+        [
+          ['invalid-uuid', $this->term1->uuid()],
+          $this->user2->uuid(),
+        ],
+        [
+          [$this->term1->id()],
+          $this->user2->id(),
+        ],
+        'taxonomy_term--tags:invalid-uuid',
+      ],
+      // Bad data in user and first tag.
+      [
+        [
+          ['invalid-uuid', $this->term1->uuid()],
+          'also-invalid-uuid',
+        ],
+        [
+          [$this->term1->id()],
+          NULL,
+        ],
+        'user--user:also-invalid-uuid',
+      ],
+    ];
+
+    foreach ($configurations as $configuration) {
+      list($payload_data, $expected) = $this->denormalizeUuidProviderBuilder($configuration);
+      $payload = Json::encode($payload_data);
+
+      list($request, $resource_type) = $this->generateProphecies('node', 'article');
+      $this->container->get('request_stack')->push($request);
+      try {
+        $node = $this
+          ->getNormalizer()
+          ->denormalize(Json::decode($payload), NULL, 'api_json', [
+            'resource_type' => $resource_type,
+          ]);
+      }
+      catch (NotFoundHttpException $e) {
+        $non_existing_resource_identifier = $configuration[2];
+        $this->assertEquals("The resource identified by `$non_existing_resource_identifier` (given as a relationship item) could not be found.", $e->getMessage());
+        continue;
+      }
+
+      /* @var \Drupal\node\Entity\Node $node */
+      $this->assertInstanceOf(Node::class, $node);
+      $this->assertSame('Testing article', $node->getTitle());
+      if (!empty($expected['user_id'])) {
+        $owner = $node->getOwner();
+        $this->assertEquals($expected['user_id'], $owner->id());
+      }
+      $tags = $node->get('field_tags')->getValue();
+      if (!empty($expected['tag_ids'][0])) {
+        $this->assertEquals($expected['tag_ids'][0], $tags[0]['target_id']);
+      }
+      else {
+        $this->assertArrayNotHasKey(0, $tags);
+      }
+      if (!empty($expected['tag_ids'][1])) {
+        $this->assertEquals($expected['tag_ids'][1], $tags[1]['target_id']);
+      }
+      else {
+        $this->assertArrayNotHasKey(1, $tags);
+      }
+    }
+  }
+
+  /**
+   * Tests denormalization for related resources with missing or invalid types.
+   */
+  public function testDenormalizeInvalidTypeAndNoType() {
+    $payload_data = [
+      'data' => [
+        'type' => 'node--article',
+        'attributes' => [
+          'title' => 'Testing article',
+          'id' => '33095485-70D2-4E51-A309-535CC5BC0115',
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'type' => 'user--user',
+              'id' => $this->user2->uuid(),
+            ],
+          ],
+          'field_tags' => [
+            'data' => [
+              [
+                'type' => 'foobar',
+                'id' => $this->term1->uuid(),
+              ],
+            ],
+          ],
+        ],
+      ],
+    ];
+
+    // Test relationship member with invalid type.
+    $payload = Json::encode($payload_data);
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $this->container->get('request_stack')->push($request);
+    try {
+      $this
+        ->getNormalizer()
+        ->denormalize(Json::decode($payload), NULL, 'api_json', [
+          'resource_type' => $resource_type,
+        ]);
+
+      $this->fail('No assertion thrown for invalid type');
+    }
+    catch (BadRequestHttpException $e) {
+      $this->assertEquals("Invalid type specified for related resource: 'foobar'", $e->getMessage());
+    }
+
+    // Test relationship member with no type.
+    unset($payload_data['data']['relationships']['field_tags']['data'][0]['type']);
+
+    $payload = Json::encode($payload_data);
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $this->container->get('request_stack')->push($request);
+    try {
+      $this->container->get('jsonapi_test_normalizers_kernel.jsonapi_document_toplevel')
+        ->denormalize(Json::decode($payload), NULL, 'api_json', [
+          'resource_type' => $resource_type,
+        ]);
+
+      $this->fail('No assertion thrown for missing type');
+    }
+    catch (BadRequestHttpException $e) {
+      $this->assertEquals("No type specified for related resource", $e->getMessage());
+    }
+  }
+
+  /**
+   * We cannot use a PHPUnit data provider because our data depends on $this.
+   *
+   * @param array $options
+   *   Options for how to construct test data.
+   *
+   * @return array
+   *   The test data.
+   */
+  protected function denormalizeUuidProviderBuilder(array $options) {
+    list($input, $expected) = $options;
+    list($input_tag_uuids, $input_user_uuid) = $input;
+    list($expected_tag_ids, $expected_user_id) = $expected;
+
+    $node = [
+      [
+        'data' => [
+          'type' => 'node--article',
+          'attributes' => [
+            'title' => 'Testing article',
+          ],
+          'relationships' => [
+            'uid' => [
+              'data' => [
+                'type' => 'user--user',
+                'id' => $input_user_uuid,
+              ],
+            ],
+            'field_tags' => [
+              'data' => [],
+            ],
+          ],
+        ],
+      ],
+      [
+        'tag_ids' => $expected_tag_ids,
+        'user_id' => $expected_user_id,
+      ],
+    ];
+
+    if (isset($input_tag_uuids[0])) {
+      $node[0]['data']['relationships']['field_tags']['data'][0] = [
+        'type' => 'taxonomy_term--tags',
+        'id' => $input_tag_uuids[0],
+      ];
+    }
+    if (isset($input_tag_uuids[1])) {
+      $node[0]['data']['relationships']['field_tags']['data'][1] = [
+        'type' => 'taxonomy_term--tags',
+        'id' => $input_tag_uuids[1],
+      ];
+    }
+    return $node;
+  }
+
+  /**
+   * Ensure that cacheability metadata is properly added.
+   *
+   * @param \Drupal\Core\Cache\CacheableMetadata $expected_metadata
+   *   The expected cacheable metadata.
+   * @param array|null $fields
+   *   Fields to include in the response, keyed by resource type.
+   * @param array|null $includes
+   *   Resources paths to include in the response.
+   *
+   * @dataProvider testCacheableMetadataProvider
+   */
+  public function testCacheableMetadata(CacheableMetadata $expected_metadata, $fields = NULL, $includes = NULL) {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $resource_object = new ResourceObject($resource_type, $this->node);
+    $context = [
+      'resource_type' => $resource_type,
+      'account' => NULL,
+    ];
+    $jsonapi_doc_object = $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel($resource_object, new NullEntityCollection(), new LinkCollection([])), 'api_json', $context);
+    $this->assertArraySubset($expected_metadata->getCacheTags(), $jsonapi_doc_object->getCacheTags());
+    $this->assertArraySubset($expected_metadata->getCacheContexts(), $jsonapi_doc_object->getCacheContexts());
+    $this->assertSame($expected_metadata->getCacheMaxAge(), $jsonapi_doc_object->getCacheMaxAge());
+  }
+
+  /**
+   * Provides test cases for asserting cacheable metadata behavior.
+   */
+  public function testCacheableMetadataProvider() {
+    $cacheable_metadata = function ($metadata) {
+      return CacheableMetadata::createFromRenderArray(['#cache' => $metadata]);
+    };
+
+    return [
+      [
+        $cacheable_metadata(['contexts' => ['languages:language_interface']]),
+        ['node--article' => 'body'],
+      ],
+    ];
+  }
+
+  /**
+   * Decorates a request with sparse fieldsets and includes.
+   */
+  protected function decorateRequest(Request $request, array $fields = NULL, array $includes = NULL) {
+    $parameters = new ParameterBag();
+    $parameters->add($fields ? ['fields' => $fields] : []);
+    $parameters->add($includes ? ['include' => $includes] : []);
+    $request->query = $parameters;
+    return $request;
+  }
+
+  /**
+   * Helper to load the normalizer.
+   */
+  protected function getNormalizer() {
+    $normalizer_service = $this->container->get('jsonapi_test_normalizers_kernel.jsonapi_document_toplevel');
+    // Simulate what happens when this normalizer service is used via the
+    // serializer service, as it is meant to be used.
+    $normalizer_service->setSerializer($this->container->get('jsonapi.serializer'));
+    return $normalizer_service;
+  }
+
+  /**
+   * Generates the prophecies for the mocked entity request.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type. Ex: node.
+   * @param string $bundle
+   *   The bundle. Ex: article.
+   *
+   * @return array
+   *   A numeric array containing the request and the ResourceType.
+   *
+   * @throws \Exception
+   */
+  protected function generateProphecies($entity_type_id, $bundle) {
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->get($entity_type_id, $bundle);
+
+    return [new Request(), $resource_type];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php
new file mode 100644
index 0000000000..3c1e3820b1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php
@@ -0,0 +1,407 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Query;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\Filter
+ * @group jsonapi
+ * @group jsonapi_query
+ * @group legacy
+ *
+ * @internal
+ */
+class FilterTest extends JsonapiKernelTestBase {
+
+  use ImageFieldCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    'file',
+    'image',
+    'jsonapi',
+    'node',
+    'serialization',
+    'system',
+    'text',
+    'user',
+  ];
+
+  /**
+   * A node storage instance.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $nodeStorage;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->setUpSchemas();
+
+    $this->savePaintingType();
+
+    // ((RED or CIRCLE) or (YELLOW and SQUARE))
+    $this->savePaintings([
+      ['colors' => ['red'], 'shapes' => ['triangle'], 'title' => 'FIND'],
+      ['colors' => ['orange'], 'shapes' => ['circle'], 'title' => 'FIND'],
+      ['colors' => ['orange'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
+      ['colors' => ['yellow'], 'shapes' => ['square'], 'title' => 'FIND'],
+      ['colors' => ['yellow'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
+      ['colors' => ['orange'], 'shapes' => ['square'], 'title' => 'DONT_FIND'],
+    ]);
+
+    $this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node');
+    $this->fieldResolver = $this->container->get('jsonapi.field_resolver');
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueToMissingPropertyName() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The field `colors`, given in the path `colors` is incomplete, it must end with one of the following specifiers: `value`, `format`, `processed`.');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['colors' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueToMissingPropertyNameReferenceFieldWithMetaProperties() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The field `photo`, given in the path `photo` is incomplete, it must end with one of the following specifiers: `id`, `meta.alt`, `meta.title`, `meta.width`, `meta.height`.');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['photo' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueMissingMetaPrefixReferenceFieldWithMetaProperties() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The property `alt`, given in the path `photo.alt` belongs to the meta object of a relationship and must be preceded by `meta`.');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['photo.alt' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueToMissingPropertyNameReferenceFieldWithoutMetaProperties() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The field `uid`, given in the path `uid` is incomplete, it must end with one of the following specifiers: `id`.');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['uid' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueToNonexistentProperty() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The property `foobar`, given in the path `colors.foobar`, does not exist. Must be one of the following property names: `value`, `format`, `processed`.');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['colors.foobar' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testInvalidFilterPathDueToElidedSoleProperty() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'Invalid nested filtering. The property `value`, given in the path `promote.value`, does not exist. Filter by `promote`, not `promote.value` (the JSON:API module elides property names from single-property fields).');
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    Filter::createFromQueryParameter(['promote.value' => ''], $resource_type, $this->fieldResolver);
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testQueryCondition() {
+    // Can't use a data provider because we need access to the container.
+    $data = $this->queryConditionData();
+
+    $get_sql_query_for_entity_query = function ($entity_query) {
+      // Expose parts of \Drupal\Core\Entity\Query\Sql\Query::execute().
+      $o = new \ReflectionObject($entity_query);
+      $m1 = $o->getMethod('prepare');
+      $m1->setAccessible(TRUE);
+      $m2 = $o->getMethod('compile');
+      $m2->setAccessible(TRUE);
+
+      // The private property computed by the two previous private calls, whose
+      // value we need to inspect.
+      $p = $o->getProperty('sqlQuery');
+      $p->setAccessible(TRUE);
+
+      $m1->invoke($entity_query);
+      $m2->invoke($entity_query);
+      return (string) $p->getValue($entity_query);
+    };
+
+    $resource_type = new ResourceType('node', 'painting', NULL);
+    foreach ($data as $case) {
+      $parameter = $case[0];
+      $expected_query = $case[1];
+      $filter = Filter::createFromQueryParameter($parameter, $resource_type, $this->fieldResolver);
+
+      $query = $this->nodeStorage->getQuery();
+
+      // Get the query condition parsed from the input.
+      $condition = $filter->queryCondition($query);
+
+      // Apply it to the query.
+      $query->condition($condition);
+
+      // Verify the SQL query is exactly the same.
+      $expected_sql_query = $get_sql_query_for_entity_query($expected_query);
+      $actual_sql_query = $get_sql_query_for_entity_query($query);
+      $this->assertSame($expected_sql_query, $actual_sql_query);
+
+      // Compare the results.
+      $this->assertEquals($expected_query->execute(), $query->execute());
+    }
+  }
+
+  /**
+   * Simply provides test data to keep the actual test method tidy.
+   */
+  protected function queryConditionData() {
+    // ((RED or CIRCLE) or (YELLOW and SQUARE))
+    $query = $this->nodeStorage->getQuery();
+
+    $or_group = $query->orConditionGroup();
+
+    $nested_or_group = $query->orConditionGroup();
+    $nested_or_group->condition('colors', 'red', 'CONTAINS');
+    $nested_or_group->condition('shapes', 'circle', 'CONTAINS');
+    $or_group->condition($nested_or_group);
+
+    $nested_and_group = $query->andConditionGroup();
+    $nested_and_group->condition('colors', 'yellow', 'CONTAINS');
+    $nested_and_group->condition('shapes', 'square', 'CONTAINS');
+    $nested_and_group->notExists('photo.alt');
+    $or_group->condition($nested_and_group);
+
+    $query->condition($or_group);
+
+    return [
+      [
+        [
+          'or-group' => ['group' => ['conjunction' => 'OR']],
+          'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']],
+          'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']],
+          'condition-0' => [
+            'condition' => [
+              'path' => 'colors.value',
+              'value' => 'red',
+              'operator' => 'CONTAINS',
+              'memberOf' => 'nested-or-group',
+            ],
+          ],
+          'condition-1' => [
+            'condition' => [
+              'path' => 'shapes.value',
+              'value' => 'circle',
+              'operator' => 'CONTAINS',
+              'memberOf' => 'nested-or-group',
+            ],
+          ],
+          'condition-2' => [
+            'condition' => [
+              'path' => 'colors.value',
+              'value' => 'yellow',
+              'operator' =>
+              'CONTAINS',
+              'memberOf' => 'nested-and-group',
+            ],
+          ],
+          'condition-3' => [
+            'condition' => [
+              'path' => 'shapes.value',
+              'value' => 'square',
+              'operator' => 'CONTAINS',
+              'memberOf' => 'nested-and-group',
+            ],
+          ],
+          'condition-4' => [
+            'condition' => [
+              'path' => 'photo.meta.alt',
+              'operator' => 'IS NULL',
+              'memberOf' => 'nested-and-group',
+            ],
+          ],
+        ],
+        $query,
+      ],
+    ];
+  }
+
+  /**
+   * Sets up the schemas.
+   */
+  protected function setUpSchemas() {
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+
+    $this->installSchema('user', []);
+    foreach (['user', 'node'] as $entity_type_id) {
+      $this->installEntitySchema($entity_type_id);
+    }
+  }
+
+  /**
+   * Creates a painting node type.
+   */
+  protected function savePaintingType() {
+    NodeType::create([
+      'type' => 'painting',
+    ])->save();
+    $this->createTextField(
+      'node', 'painting',
+      'colors', 'Colors',
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->createTextField(
+      'node', 'painting',
+      'shapes', 'Shapes',
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->createImageField('photo', 'painting');
+  }
+
+  /**
+   * Creates painting nodes.
+   */
+  protected function savePaintings($paintings) {
+    foreach ($paintings as $painting) {
+      Node::create(array_merge([
+        'type' => 'painting',
+      ], $painting))->save();
+    }
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   * @dataProvider parameterProvider
+   */
+  public function testCreateFromQueryParameter($case, $expected) {
+    $resource_type = new ResourceType('foo', 'bar', NULL);
+    $actual = Filter::createFromQueryParameter($case, $resource_type, $this->getFieldResolverMock($resource_type));
+    $conditions = $actual->root()->members();
+    for ($i = 0; $i < count($case); $i++) {
+      $this->assertEquals($expected[$i]['path'], $conditions[$i]->field());
+      $this->assertEquals($expected[$i]['value'], $conditions[$i]->value());
+      $this->assertEquals($expected[$i]['operator'], $conditions[$i]->operator());
+    }
+  }
+
+  /**
+   * Data provider for testCreateFromQueryParameter.
+   */
+  public function parameterProvider() {
+    return [
+      'shorthand' => [
+        ['uid' => ['value' => 1]],
+        [['path' => 'uid', 'value' => 1, 'operator' => '=']],
+      ],
+      'extreme shorthand' => [
+        ['uid' => 1],
+        [['path' => 'uid', 'value' => 1, 'operator' => '=']],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   */
+  public function testCreateFromQueryParameterNested() {
+    $parameter = [
+      'or-group' => ['group' => ['conjunction' => 'OR']],
+      'nested-or-group' => [
+        'group' => ['conjunction' => 'OR', 'memberOf' => 'or-group'],
+      ],
+      'nested-and-group' => [
+        'group' => ['conjunction' => 'AND', 'memberOf' => 'or-group'],
+      ],
+      'condition-0' => [
+        'condition' => [
+          'path' => 'field0',
+          'value' => 'value0',
+          'memberOf' => 'nested-or-group',
+        ],
+      ],
+      'condition-1' => [
+        'condition' => [
+          'path' => 'field1',
+          'value' => 'value1',
+          'memberOf' => 'nested-or-group',
+        ],
+      ],
+      'condition-2' => [
+        'condition' => [
+          'path' => 'field2',
+          'value' => 'value2',
+          'memberOf' => 'nested-and-group',
+        ],
+      ],
+      'condition-3' => [
+        'condition' => [
+          'path' => 'field3',
+          'value' => 'value3',
+          'memberOf' => 'nested-and-group',
+        ],
+      ],
+    ];
+    $resource_type = new ResourceType('foo', 'bar', NULL);
+    $filter = Filter::createFromQueryParameter($parameter, $resource_type, $this->getFieldResolverMock($resource_type));
+    $root = $filter->root();
+
+    // Make sure the implicit root group was added.
+    $this->assertEquals($root->conjunction(), 'AND');
+
+    // Ensure the or-group and the and-group were added correctly.
+    $members = $root->members();
+
+    // Ensure the OR group was added.
+    $or_group = $members[0];
+    $this->assertEquals($or_group->conjunction(), 'OR');
+    $or_group_members = $or_group->members();
+
+    // Make sure the nested OR group was added with the right conditions.
+    $nested_or_group = $or_group_members[0];
+    $this->assertEquals($nested_or_group->conjunction(), 'OR');
+    $nested_or_group_members = $nested_or_group->members();
+    $this->assertEquals($nested_or_group_members[0]->field(), 'field0');
+    $this->assertEquals($nested_or_group_members[1]->field(), 'field1');
+
+    // Make sure the nested AND group was added with the right conditions.
+    $nested_and_group = $or_group_members[1];
+    $this->assertEquals($nested_and_group->conjunction(), 'AND');
+    $nested_and_group_members = $nested_and_group->members();
+    $this->assertEquals($nested_and_group_members[0]->field(), 'field2');
+    $this->assertEquals($nested_and_group_members[1]->field(), 'field3');
+  }
+
+  /**
+   * Provides a mock field resolver.
+   */
+  protected function getFieldResolverMock(ResourceType $resource_type) {
+    $field_resolver = $this->prophesize(FieldResolver::class);
+    $field_resolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), Argument::any())->willReturnArgument(2);
+    return $field_resolver->reveal();
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php b/core/modules/jsonapi/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php
new file mode 100644
index 0000000000..3ca39f855f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/ResourceType/RelatedResourceTypesTest.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\ResourceType;
+
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\ResourceType\ResourceType
+ * @coversClass \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RelatedResourceTypesTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'jsonapi',
+    'serialization',
+    'system',
+    'user',
+    'field',
+  ];
+
+  /**
+   * The JSON:API resource type repository under test.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The JSON:API resource type for `node--foo`.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $fooType;
+
+  /**
+   * The JSON:API resource type for `node--bar`.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $barType;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+
+    NodeType::create([
+      'type' => 'foo',
+    ])->save();
+
+    NodeType::create([
+      'type' => 'bar',
+    ])->save();
+
+    $this->createEntityReferenceField(
+      'node',
+      'foo',
+      'field_ref_bar',
+      'Bar Reference',
+      'node',
+      'default',
+      ['target_bundles' => ['bar']]
+    );
+
+    $this->createEntityReferenceField(
+      'node',
+      'foo',
+      'field_ref_foo',
+      'Foo Reference',
+      'node',
+      'default',
+      // Important to test self-referencing resource types.
+      ['target_bundles' => ['foo']]
+    );
+
+    $this->createEntityReferenceField(
+      'node',
+      'foo',
+      'field_ref_any',
+      'Any Bundle Reference',
+      'node',
+      'default',
+      // This should result in a reference to any bundle.
+      ['target_bundles' => NULL]
+    );
+
+    $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
+  }
+
+  /**
+   * @covers ::getRelatableResourceTypes
+   * @dataProvider getRelatableResourceTypesProvider
+   */
+  public function testGetRelatableResourceTypes($resource_type_name, $relatable_type_names) {
+    // We're only testing the fields that we set up.
+    $test_fields = [
+      'field_ref_foo',
+      'field_ref_bar',
+      'field_ref_any',
+    ];
+
+    $resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name);
+
+    // This extracts just the relationship fields under test.
+    $subjects = array_intersect_key(
+      $resource_type->getRelatableResourceTypes(),
+      array_flip($test_fields)
+    );
+
+    // Map the related resource type to their type name so we can just compare
+    // the type names rather that the whole object.
+    foreach ($test_fields as $field_name) {
+      if (isset($subjects[$field_name])) {
+        $subjects[$field_name] = array_map(function ($resource_type) {
+          return $resource_type->getTypeName();
+        }, $subjects[$field_name]);
+      }
+    }
+
+    $this->assertArraySubset($relatable_type_names, $subjects);
+  }
+
+  /**
+   * @covers ::getRelatableResourceTypes
+   * @dataProvider getRelatableResourceTypesProvider
+   */
+  public function getRelatableResourceTypesProvider() {
+    return [
+      [
+        'node--foo',
+        [
+          'field_ref_foo' => ['node--foo'],
+          'field_ref_bar' => ['node--bar'],
+          'field_ref_any' => ['node--foo', 'node--bar'],
+        ],
+      ],
+      ['node--bar', []],
+    ];
+  }
+
+  /**
+   * @covers ::getRelatableResourceTypesByField
+   * @dataProvider getRelatableResourceTypesByFieldProvider
+   */
+  public function testGetRelatableResourceTypesByField($entity_type_id, $bundle, $field) {
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    $relatable_types = $resource_type->getRelatableResourceTypes();
+    $this->assertSame(
+      $relatable_types[$field],
+      $resource_type->getRelatableResourceTypesByField($field)
+    );
+  }
+
+  /**
+   * Provides cases to test getRelatableTypesByField.
+   */
+  public function getRelatableResourceTypesByFieldProvider() {
+    return [
+      ['node', 'foo', 'field_ref_foo'],
+      ['node', 'foo', 'field_ref_bar'],
+      ['node', 'foo', 'field_ref_any'],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
new file mode 100644
index 0000000000..34da1445a7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\ResourceType;
+
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class ResourceTypeRepositoryTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'jsonapi',
+    'serialization',
+    'system',
+    'user',
+  ];
+
+  /**
+   * The JSON:API resource type repository under test.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    NodeType::create([
+      'type' => 'article',
+    ])->save();
+    NodeType::create([
+      'type' => 'page',
+    ])->save();
+
+    $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository');
+  }
+
+  /**
+   * @covers ::all
+   */
+  public function testAll() {
+    // Make sure that there are resources being created.
+    $all = $this->resourceTypeRepository->all();
+    $this->assertNotEmpty($all);
+    array_walk($all, function (ResourceType $resource_type) {
+      $this->assertNotEmpty($resource_type->getDeserializationTargetClass());
+      $this->assertNotEmpty($resource_type->getEntityTypeId());
+      $this->assertNotEmpty($resource_type->getTypeName());
+    });
+  }
+
+  /**
+   * @covers ::get
+   * @dataProvider getProvider
+   */
+  public function testGet($entity_type_id, $bundle, $entity_class) {
+    // Make sure that there are resources being created.
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    $this->assertInstanceOf(ResourceType::class, $resource_type);
+    $this->assertSame($entity_class, $resource_type->getDeserializationTargetClass());
+    $this->assertSame($entity_type_id, $resource_type->getEntityTypeId());
+    $this->assertSame($bundle, $resource_type->getBundle());
+    $this->assertSame($entity_type_id . '--' . $bundle, $resource_type->getTypeName());
+  }
+
+  /**
+   * Data provider for testGet.
+   *
+   * @returns array
+   *   The data for the test method.
+   */
+  public function getProvider() {
+    return [
+      ['node', 'article', 'Drupal\node\Entity\Node'],
+      ['node_type', 'node_type', 'Drupal\node\Entity\NodeType'],
+      ['menu', 'menu', 'Drupal\system\Entity\Menu'],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Revisions/VersionNegotiatorTest.php b/core/modules/jsonapi/tests/src/Kernel/Revisions/VersionNegotiatorTest.php
new file mode 100644
index 0000000000..e432e8ff9f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Revisions/VersionNegotiatorTest.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Revisions;
+
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
+use Drupal\jsonapi\Revisions\VersionById;
+use Drupal\jsonapi\Revisions\VersionByRel;
+use Drupal\jsonapi\Revisions\VersionNegotiator;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\user\Entity\User;
+
+/**
+ * The test class for version negotiators.
+ *
+ * @coversDefaultClass \Drupal\jsonapi\Revisions\VersionNegotiator
+ * @group jsonapi
+ *
+ * @internal
+ */
+class VersionNegotiatorTest extends JsonapiKernelTestBase {
+
+  /**
+   * The user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * The node.
+   *
+   * @var \Drupal\node\Entity\Node
+   */
+  protected $node;
+
+  /**
+   * The previous revision ID of $node.
+   *
+   * @var string
+   */
+  protected $nodePreviousRevisionId;
+
+  /**
+   * The version negotiator service.
+   *
+   * @var \Drupal\jsonapi\Revisions\VersionNegotiator
+   */
+  protected $versionNegotiator;
+
+  /**
+   * The other node.
+   *
+   * @var \Drupal\node\Entity\Node
+   */
+  protected $node2;
+
+  public static $modules = [
+    'node',
+    'field',
+    'jsonapi',
+    'serialization',
+    'system',
+    'user',
+  ];
+
+  /**
+   * Initialization tasks for the test.
+   *
+   * @inheritdoc
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    $type = NodeType::create([
+      'type' => 'dummy',
+      'new_revision' => TRUE,
+    ]);
+    $type->save();
+
+    $this->user = User::create([
+      'name' => 'user1',
+      'mail' => 'user@localhost',
+      'status' => 1,
+    ]);
+    $this->user->save();
+
+    $this->node = Node::create([
+      'title' => 'dummy_title',
+      'type' => 'dummy',
+      'uid' => $this->user->id(),
+    ]);
+    $this->node->save();
+
+    $this->nodePreviousRevisionId = $this->node->getRevisionId();
+
+    $this->node->setNewRevision();
+    $this->node->setTitle('revised_dummy_title');
+    $this->node->save();
+
+    $this->node2 = Node::create([
+      'type' => 'dummy',
+      'title' => 'Another test node',
+      'uid' => $this->user->id(),
+    ]);
+    $this->node2->save();
+
+    $entity_type_manager = \Drupal::entityTypeManager();
+    $version_negotiator = new VersionNegotiator();
+    $version_negotiator->addVersionNegotiator(new VersionById($entity_type_manager), 'id');
+    $version_negotiator->addVersionNegotiator(new VersionByRel($entity_type_manager), 'rel');
+    $this->versionNegotiator = $version_negotiator;
+
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Revisions\VersionById::getRevision
+   */
+  public function testOldRevision() {
+    $revision = $this->versionNegotiator->getRevision($this->node, 'id:' . $this->nodePreviousRevisionId);
+    $this->assertEquals($this->node->id(), $revision->id());
+    $this->assertEquals($this->nodePreviousRevisionId, $revision->getRevisionId());
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Revisions\VersionById::getRevision
+   */
+  public function testInvalidRevisionId() {
+    $this->setExpectedException(CacheableNotFoundHttpException::class, sprintf('The requested version, identified by `id:%s`, could not be found.', $this->node2->getRevisionId()));
+    $this->versionNegotiator->getRevision($this->node, 'id:' . $this->node2->getRevisionId());
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
+   */
+  public function testLatestVersion() {
+    $revision = $this->versionNegotiator->getRevision($this->node, 'rel:' . VersionByRel::LATEST_VERSION);
+    $this->assertEquals($this->node->id(), $revision->id());
+    $this->assertEquals($this->node->getRevisionId(), $revision->getRevisionId());
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
+   */
+  public function testCurrentVersion() {
+    $revision = $this->versionNegotiator->getRevision($this->node, 'rel:' . VersionByRel::WORKING_COPY);
+    $this->assertEquals($this->node->id(), $revision->id());
+    $this->assertEquals($this->node->id(), $revision->id());
+    $this->assertEquals($this->node->getRevisionId(), $revision->getRevisionId());
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Revisions\VersionByRel::getRevision
+   */
+  public function testInvalidRevisionRel() {
+    $this->setExpectedException(CacheableBadRequestHttpException::class, 'An invalid resource version identifier, `rel:erroneous-revision-name`, was provided.');
+    $this->versionNegotiator->getRevision($this->node, 'rel:erroneous-revision-name');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
new file mode 100644
index 0000000000..97f0c06be4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Serializer;
+
+use Drupal\Core\Render\Markup;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi_test_data_type\TraversableObject;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Drupal\user\Entity\User;
+
+/**
+ * Tests the JSON:API serializer.
+ *
+ * @coversClass \Drupal\jsonapi\Serializer\Serializer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class SerializerTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'node',
+    'user',
+    'field',
+    'text',
+    'filter',
+    'jsonapi_test_data_type',
+  ];
+
+  /**
+   * An entity for testing.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $node;
+
+  /**
+   * The subject under test.
+   *
+   * @var \Drupal\jsonapi\Serializer\Serializer
+   */
+  protected $sut;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Add the entity schemas.
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    $this->user = User::create([
+      'name' => $this->randomString(),
+      'status' => 1,
+    ]);
+    $this->user->save();
+    NodeType::create([
+      'type' => 'foo',
+    ])->save();
+    $this->createTextField('node', 'foo', 'field_text', 'Text');
+    $this->node = Node::create([
+      'title' => 'Test Node',
+      'type' => 'foo',
+      'field_text' => [
+        'value' => 'This is some text.',
+        'format' => 'text_plain',
+      ],
+      'uid' => $this->user->id(),
+    ]);
+    $this->node->save();
+    $this->container->setAlias('sut', 'jsonapi.serializer');
+    $this->sut = $this->container->get('sut');
+  }
+
+  /**
+   * @covers \Drupal\jsonapi\Serializer\Serializer::normalize
+   */
+  public function testFallbackNormalizer() {
+    $context = ['account' => $this->user];
+
+    $value = $this->sut->normalize($this->node->field_text, 'api_json', $context);
+    $this->assertTrue($value instanceof CacheableNormalization);
+
+    $nested_field = [
+      $this->node->field_text,
+    ];
+
+    // When an object implements \IteratorAggregate and has corresponding
+    // fallback normalizer, it should be normalized by fallback normalizer.
+    $traversableObject = new TraversableObject();
+    $value = $this->sut->normalize($traversableObject, 'api_json', $context);
+    $this->assertEquals($traversableObject->property, $value);
+
+    // When wrapped in an array, we should still be using the JSON:API
+    // serializer.
+    $value = $this->sut->normalize($nested_field, 'api_json', $context);
+    $this->assertTrue($value[0] instanceof CacheableNormalization);
+
+    // Continue to use the fallback normalizer when we need it.
+    $data = Markup::create('<h2>Test Markup</h2>');
+    $value = $this->sut->normalize($data, 'api_json', $context);
+
+    $this->assertEquals('<h2>Test Markup</h2>', $value);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php b/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
new file mode 100644
index 0000000000..8ac8a39ec6
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Traits;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
+use Drupal\Tests\jsonapi\Functional\ResourceTestBase;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * Provides common filter access control tests.
+ */
+trait CommonCollectionFilterAccessTestPatternsTrait {
+
+  use EntityReferenceTestTrait;
+
+  /**
+   * Implements ::testCollectionFilterAccess() for pure permission-based access.
+   *
+   * @param string $label_field_name
+   *   The entity type's label field name.
+   * @param string $view_permission
+   *   The entity type's permission that grants 'view' access.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The referencing entity.
+   */
+  public function doTestCollectionFilterAccessBasedOnPermissions($label_field_name, $view_permission) {
+    assert($this instanceof ResourceTestBase);
+
+    // Set up data model.
+    $this->assertTrue($this->container->get('module_installer')->install(['entity_test'], TRUE), 'Installed modules.');
+    entity_test_create_bundle('bar', NULL, 'entity_test');
+    $this->createEntityReferenceField(
+      'entity_test',
+      'bar',
+      'spotlight',
+      NULL,
+      static::$entityTypeId,
+      'default',
+      [
+        'target_bundles' => [
+          $this->entity->bundle() => $this->entity->bundle(),
+        ],
+      ]
+    );
+    $this->rebuildAll();
+    $this->grantPermissionsToTestedRole(['view test entity']);
+
+    // Create data.
+    $referencing_entity = EntityTest::create([
+      'name' => 'Camelids',
+      'type' => 'bar',
+      'spotlight' => [
+        'target_id' => $this->entity->id(),
+      ],
+    ]);
+    $referencing_entity->save();
+
+    // Test.
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    // Specifying a delta exercises TemporaryQueryGaurd more thoroughly.
+    $filter_path = "spotlight.0.$label_field_name";
+    $collection_filter_url = $collection_url->setOption('query', ["filter[$filter_path]" => $this->entity->label()]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    if ($view_permission !== NULL) {
+      // ?filter[spotlight.LABEL]: 0 results.
+      $response = $this->request('GET', $collection_filter_url, $request_options);
+      $doc = Json::decode((string) $response->getBody());
+      $this->assertCount(0, $doc['data']);
+      // Grant "view" permission.
+      $this->grantPermissionsToTestedRole([$view_permission]);
+    }
+    // ?filter[spotlight.LABEL]: 1 result.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($referencing_entity->uuid(), $doc['data'][0]['id']);
+
+    // ?filter[spotlight.LABEL]: 1 result.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($referencing_entity->uuid(), $doc['data'][0]['id']);
+
+    // Install the jsonapi_test_field_filter_access module, which contains a
+    // hook_jsonapi_entity_field_filter_access() implementation that forbids
+    // access to the spotlight field if the 'filter by spotlight field'
+    // permission is not granted.
+    $this->assertTrue($this->container->get('module_installer')->install(['jsonapi_test_field_filter_access'], TRUE), 'Installed modules.');
+    $this->rebuildAll();
+
+    // Ensure that a 403 response is generated for attempting to filter by a
+    // field that is forbidden by an implementation of
+    // hook_jsonapi_entity_field_filter_access() .
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $message = "The current user is not authorized to filter by the `spotlight` field, given in the path `spotlight`.";
+    $expected_cache_tags = ['4xx-response', 'http_response'];
+    $expected_cache_contexts = [
+      'url.query_args:filter',
+      'url.query_args:sort',
+      'url.site',
+      'user.permissions',
+    ];
+    $this->assertResourceErrorResponse(403, $message, $collection_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+    // And ensure the it is allowed when the proper permission is granted.
+    $this->grantPermissionsToTestedRole(['filter by spotlight field']);
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($referencing_entity->uuid(), $doc['data'][0]['id']);
+    $this->revokePermissionsFromTestedRole(['filter by spotlight field']);
+
+    $this->assertTrue($this->container->get('module_installer')->uninstall(['jsonapi_test_field_filter_access'], TRUE), 'Uninstalled modules.');
+
+    return $referencing_entity;
+  }
+
+  /**
+   * Implements ::testCollectionFilterAccess() for permission + status access.
+   *
+   * @param string $label_field_name
+   *   The entity type's label field name.
+   * @param string $view_permission
+   *   The entity type's permission that grants 'view' access (for published
+   *   entities of this type).
+   * @param string $admin_permission
+   *   The entity type's permission that grants 'view' access (for unpublished
+   *   entities of this type).
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The referencing entity.
+   */
+  public function doTestCollectionFilterAccessForPublishableEntities($label_field_name, $view_permission, $admin_permission) {
+    assert($this->entity instanceof EntityPublishedInterface);
+    $this->assertTrue($this->entity->isPublished());
+
+    $referencing_entity = $this->doTestCollectionFilterAccessBasedOnPermissions($label_field_name, $view_permission);
+
+    $collection_url = Url::fromRoute('jsonapi.entity_test--bar.collection');
+    $collection_filter_url = $collection_url->setOption('query', ["filter[spotlight.$label_field_name]" => $this->entity->label()]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // Unpublish.
+    $this->entity->setUnpublished()->save();
+    // ?filter[spotlight.LABEL]: no result because the test entity is
+    // unpublished. This proves that appropriate cache tags are bubbled.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(0, $doc['data']);
+    // Grant admin permission.
+    $this->grantPermissionsToTestedRole([$admin_permission]);
+    // ?filter[spotlight.LABEL]: 1 result despite the test entity being
+    // unpublished, thanks to the admin permission. This proves that the
+    // appropriate cache contexts are bubbled.
+    $response = $this->request('GET', $collection_filter_url, $request_options);
+    $doc = Json::decode((string) $response->getBody());
+    $this->assertCount(1, $doc['data']);
+    $this->assertSame($referencing_entity->uuid(), $doc['data'][0]['id']);
+
+    return $referencing_entity;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php b/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php
new file mode 100644
index 0000000000..7e79c187fc
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\EventSubscriber;
+
+use Drupal\jsonapi\Encoder\JsonEncoder;
+use Drupal\jsonapi\EventSubscriber\ResourceResponseValidator;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Routing\Routes;
+use JsonSchema\Validator;
+use Drupal\Core\Extension\Extension;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\rest\ResourceResponse;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Psr\Log\LoggerInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\EventSubscriber\ResourceResponseValidator
+ * @group jsonapi
+ *
+ * @internal
+ */
+class ResourceResponseValidatorTest extends UnitTestCase {
+
+  /**
+   * The subscriber under test.
+   *
+   * @var \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber
+   */
+  protected $subscriber;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    // Check that the validation class is available.
+    if (!class_exists("\\JsonSchema\\Validator")) {
+      $this->fail('The JSON Schema validator is missing. You can install it with `composer require justinrainbow/json-schema`.');
+    }
+
+    $module_handler = $this->prophesize(ModuleHandlerInterface::class);
+    $module = $this->prophesize(Extension::class);
+    $module_path = dirname(dirname(dirname(dirname(__DIR__))));
+    $module->getPath()->willReturn($module_path);
+    $module_handler->getModule('jsonapi')->willReturn($module->reveal());
+    $encoders = [new JsonEncoder()];
+    if (class_exists(JsonSchemaEncoder::class)) {
+      $encoders[] = new JsonSchemaEncoder();
+    }
+    $subscriber = new ResourceResponseValidator(
+      new Serializer([], $encoders),
+      $this->prophesize(LoggerInterface::class)->reveal(),
+      $module_handler->reveal(),
+      ''
+    );
+    $subscriber->setValidator();
+    $this->subscriber = $subscriber;
+  }
+
+  /**
+   * @covers ::doValidateResponse
+   */
+  public function testDoValidateResponse() {
+    $request = $this->createRequest(
+      'jsonapi.node--article.individual',
+      new ResourceType('node', 'article', NULL)
+    );
+
+    $response = $this->createResponse('{"data":null}');
+
+    // Capture the default assert settings.
+    $zend_assertions_default = ini_get('zend.assertions');
+    $assert_active_default = assert_options(ASSERT_ACTIVE);
+
+    // The validator *should* be called when asserts are active.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldBeCalled('Validation should be run when asserts are active.');
+    $validator->isValid()->willReturn(TRUE);
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Ensure asset is active.
+    ini_set('zend.assertions', 1);
+    assert_options(ASSERT_ACTIVE, 1);
+    $this->subscriber->doValidateResponse($response, $request);
+
+    // The validator should *not* be called when asserts are inactive.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldNotBeCalled('Validation should not be run when asserts are not active.');
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Ensure asset is inactive.
+    ini_set('zend.assertions', 0);
+    assert_options(ASSERT_ACTIVE, 0);
+    $this->subscriber->doValidateResponse($response, $request);
+
+    // Reset the original assert values.
+    ini_set('zend.assertions', $zend_assertions_default);
+    assert_options(ASSERT_ACTIVE, $assert_active_default);
+  }
+
+  /**
+   * @covers ::validateResponse
+   * @dataProvider validateResponseProvider
+   */
+  public function testValidateResponse($request, $response, $expected, $description) {
+    // Expose protected ResourceResponseSubscriber::validateResponse() method.
+    $object = new \ReflectionObject($this->subscriber);
+    $method = $object->getMethod('validateResponse');
+    $method->setAccessible(TRUE);
+
+    $this->assertSame($expected, $method->invoke($this->subscriber, $response, $request), $description);
+  }
+
+  /**
+   * Provides test cases for testValidateResponse.
+   *
+   * @return array
+   *   An array of test cases.
+   */
+  public function validateResponseProvider() {
+    $defaults = [
+      'route_name' => 'jsonapi.node--article.individual',
+      'resource_type' => new ResourceType('node', 'article', NULL),
+    ];
+
+    $test_data = [
+      // Test validation success.
+      [
+        'json' => <<<'EOD'
+{
+  "data": {
+    "type": "node--article",
+    "id": "4f342419-e668-4b76-9f87-7ce20c436169",
+    "attributes": {
+      "nid": "1",
+      "uuid": "4f342419-e668-4b76-9f87-7ce20c436169"
+    }
+  }
+}
+EOD
+        ,
+        'expected' => TRUE,
+        'description' => 'Response validation flagged a valid response.',
+      ],
+      // Test validation failure: no "type" in "data".
+      [
+        'json' => <<<'EOD'
+{
+  "data": {
+    "id": "4f342419-e668-4b76-9f87-7ce20c436169",
+    "attributes": {
+      "nid": "1",
+      "uuid": "4f342419-e668-4b76-9f87-7ce20c436169"
+    }
+  }
+}
+EOD
+        ,
+        'expected' => FALSE,
+        'description' => 'Response validation failed to flag an invalid response.',
+      ],
+      // Test validation failure: "errors" at the root level.
+      [
+        'json' => <<<'EOD'
+{
+  "data": {
+  "type": "node--article",
+    "id": "4f342419-e668-4b76-9f87-7ce20c436169",
+    "attributes": {
+    "nid": "1",
+      "uuid": "4f342419-e668-4b76-9f87-7ce20c436169"
+    }
+  },
+  "errors": [{}]
+}
+EOD
+        ,
+        'expected' => FALSE,
+        'description' => 'Response validation failed to flag an invalid response.',
+      ],
+      // Test validation of an empty response passes.
+      [
+        'json' => NULL,
+        'expected' => TRUE,
+        'description' => 'Response validation flagged a valid empty response.',
+      ],
+      // Test validation fails on empty object.
+      [
+        'json' => '{}',
+        'expected' => FALSE,
+        'description' => 'Response validation flags empty array as invalid.',
+      ],
+    ];
+
+    $test_cases = array_map(function ($input) use ($defaults) {
+      list($json, $expected, $description, $route_name, $resource_type) = array_values($input + $defaults);
+      return [
+        $this->createRequest($route_name, $resource_type),
+        $this->createResponse($json),
+        $expected,
+        $description,
+      ];
+    }, $test_data);
+
+    return $test_cases;
+  }
+
+  /**
+   * Helper method to create a request object.
+   *
+   * @param string $route_name
+   *   The route name with which to construct a request.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for the requested route.
+   *
+   * @return \Symfony\Component\HttpFoundation\Request
+   *   The mock request object.
+   */
+  protected function createRequest($route_name, ResourceType $resource_type) {
+    $request = new Request();
+    $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name);
+    $request->attributes->set(Routes::RESOURCE_TYPE_KEY, $resource_type);
+    return $request;
+  }
+
+  /**
+   * Helper method to create a resource response from arbitrary JSON.
+   *
+   * @param string|null $json
+   *   The JSON with which to create a mock response.
+   *
+   * @return \Drupal\rest\ResourceResponse
+   *   The mock response object.
+   */
+  protected function createResponse($json = NULL) {
+    $response = new ResourceResponse();
+    if ($json) {
+      $response->setContent($json);
+    }
+    return $response;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/JsonApiSpecTest.php b/core/modules/jsonapi/tests/src/Unit/JsonApiSpecTest.php
new file mode 100644
index 0000000000..a55c09641d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/JsonApiSpecTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit;
+
+use Drupal\jsonapi\JsonApiSpec;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\JsonApiSpec
+ * @group jsonapi
+ *
+ * @internal
+ */
+class JsonApiSpecTest extends UnitTestCase {
+
+  /**
+   * Ensures that member names are properly validated.
+   *
+   * @dataProvider providerTestIsValidMemberName
+   * @covers ::isValidMemberName
+   */
+  public function testIsValidMemberName($member_name, $expected) {
+    $this->assertSame($expected, JsonApiSpec::isValidMemberName($member_name));
+  }
+
+  /**
+   * Data provider for testIsValidMemberName.
+   */
+  public function providerTestIsValidMemberName() {
+    // Copied from http://jsonapi.org/format/upcoming/#document-member-names.
+    $data = [];
+    $data['alphanumeric-lowercase'] = ['12kittens', TRUE];
+    $data['alphanumeric-uppercase'] = ['12KITTENS', TRUE];
+    $data['alphanumeric-mixed'] = ['12KiTtEnS', TRUE];
+    $data['unicode-above-u+0080'] = ['12🐱🐱', TRUE];
+    $data['hyphen-start'] = ['-kittens', FALSE];
+    $data['hyphen-middle'] = ['kitt-ens', TRUE];
+    $data['hyphen-end'] = ['kittens-', FALSE];
+    $data['lowline-start'] = ['_kittens', FALSE];
+    $data['lowline-middle'] = ['kitt_ens', TRUE];
+    $data['lowline-end'] = ['kittens_', FALSE];
+    $data['space-start'] = [' kittens', FALSE];
+    $data['space-middle'] = ['kitt ens', TRUE];
+    $data['space-end'] = ['kittens ', FALSE];
+
+    // Additional test cases.
+    // @todo When D8 requires PHP >= 7, convert to \u{10FFFF}.
+    $data['unicode-above-u+0080-highest-allowed'] = ["12􏿿", TRUE];
+    $data['single-character'] = ['a', TRUE];
+
+    $unsafe_chars = [
+      '+',
+      ',',
+      '.',
+      '[',
+      ']',
+      '!',
+      '"',
+      '#',
+      '$',
+      '%',
+      '&',
+      '\'',
+      '(',
+      ')',
+      '*',
+      '/',
+      ':',
+      ';',
+      '<',
+      '=',
+      '>',
+      '?',
+      '@',
+      '\\',
+      '^',
+      '`',
+      '{',
+      '|',
+      '}',
+      '~',
+    ];
+    foreach ($unsafe_chars as $unsafe_char) {
+      $data['unsafe-' . $unsafe_char] = ['kitt' . $unsafe_char . 'ens', FALSE];
+    }
+
+    // The ASCII control characters are in the range 0x00 to 0x1F plus 0x7F.
+    for ($ascii = 0; $ascii <= 0x1F; $ascii++) {
+      $data['unsafe-ascii-control-' . $ascii] = ['kitt' . chr($ascii) . 'ens', FALSE];
+    }
+    $data['unsafe-ascii-control-' . 0x7F] = ['kitt' . chr(0x7F) . 'ens', FALSE];
+
+    return $data;
+  }
+
+  /**
+   * Provides test cases.
+   *
+   * @dataProvider providerTestIsValidCustomQueryParameter
+   * @covers ::isValidCustomQueryParameter
+   * @covers ::isValidMemberName
+   */
+  public function testIsValidCustomQueryParameter($custom_query_parameter, $expected) {
+    $this->assertSame($expected, JsonApiSpec::isValidCustomQueryParameter($custom_query_parameter));
+  }
+
+  /**
+   * Data provider for testIsValidCustomQueryParameter.
+   */
+  public function providerTestIsValidCustomQueryParameter() {
+    $data = $this->providerTestIsValidMemberName();
+
+    // All valid member names are also valid custom query parameters, except for
+    // single-character ones.
+    $data['single-character'][1] = FALSE;
+
+    // Custom query parameter test cases.
+    $data['custom-query-parameter-lowercase'] = ['foobar', FALSE];
+    $data['custom-query-parameter-dash'] = ['foo-bar', TRUE];
+    $data['custom-query-parameter-underscore'] = ['foo_bar', TRUE];
+    $data['custom-query-parameter-camelcase'] = ['fooBar', TRUE];
+
+    return $data;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
new file mode 100644
index 0000000000..5706f99293
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
@@ -0,0 +1,218 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\LinkManager;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\GeneratedUrl;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\Core\Utility\UnroutedUrlAssemblerInterface;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\LinkManager\LinkManager
+ * @group jsonapi
+ *
+ * @internal
+ */
+class LinkManagerTest extends UnitTestCase {
+
+  /**
+   * The SUT.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $url_generator = $this->prophesize(UrlGeneratorInterface::class);
+    $url_generator->generateFromRoute(Argument::cetera())->willReturnArgument(2);
+    $this->linkManager = new LinkManager($url_generator->reveal());
+  }
+
+  /**
+   * @covers ::getPagerLinks
+   * @dataProvider getPagerLinksProvider
+   */
+  public function testGetPagerLinks($offset, $size, $has_next_page, $total, $include_count, array $pages) {
+    $assembler = $this->prophesize(UnroutedUrlAssemblerInterface::class);
+    $assembler->assemble(Argument::type('string'), Argument::type('array'), TRUE)
+      ->will(function ($args) {
+        return (new GeneratedUrl())->setGeneratedUrl($args[0] . '?' . UrlHelper::buildQuery($args[1]['query']));
+      });
+
+    $cache_contexts_manager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $cache_contexts_manager->method('assertValidTokens')->willReturn(TRUE);
+
+    $container = new ContainerBuilder();
+    $container->set('cache_contexts_manager', $cache_contexts_manager);
+    $container->set('unrouted_url_assembler', $assembler->reveal());
+    \Drupal::setContainer($container);
+
+    // Add the extra stuff to the expected query.
+    $pages = array_filter($pages);
+    $pages = array_map(function ($page) {
+      return [
+        'href' => 'https://example.com/drupal/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72?amet=pax&page%5Boffset%5D=' . $page['offset'] . '&page%5Blimit%5D=' . $page['limit'],
+      ];
+    }, $pages);
+
+    $request = $this->prophesize(Request::class);
+    $request->getUri()->willReturn('https://example.com/drupal/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72?amet=pax');
+    $request->getBaseUrl()->willReturn('/drupal');
+    $request->getPathInfo()->willReturn('');
+    $request->getSchemeAndHttpHost()->willReturn('https://example.com');
+    $request->getBaseUrl()->willReturn('/drupal');
+    $request->getPathInfo()->willReturn('/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72');
+    $request->query = new ParameterBag(['amet' => 'pax']);
+
+    $context = ['has_next_page' => $has_next_page];
+    if ($include_count) {
+      $context['total_count'] = $total;
+    }
+
+    $pager_links = $this->linkManager->getPagerLinks($request->reveal(), new OffsetPage($offset, $size), $context);
+    $mock_context = $this->prophesize(JsonApiDocumentTopLevel::class)->reveal();
+    $links = array_map(function ($links) {
+      return ['href' => reset($links)->getHref()];
+    }, iterator_to_array($pager_links->withContext($mock_context)));
+    ksort($pages);
+    ksort($links);
+    $this->assertSame($pages, $links);
+  }
+
+  /**
+   * Data provider for testGetPagerLinks.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function getPagerLinksProvider() {
+    return [
+      [1, 4, TRUE, 8, TRUE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 0, 'limit' => 4],
+        'next' => ['offset' => 5, 'limit' => 4],
+        'last' => ['offset' => 4, 'limit' => 4],
+      ],
+      ],
+      [6, 4, FALSE, 4, TRUE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 2, 'limit' => 4],
+        'next' => NULL,
+      ],
+      ],
+      [7, 4, FALSE, 5, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 3, 'limit' => 4],
+        'next' => NULL,
+      ],
+      ],
+      [10, 4, FALSE, 20, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 6, 'limit' => 4],
+        'next' => NULL,
+      ],
+      ],
+      [5, 4, TRUE, 30, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 1, 'limit' => 4],
+        'next' => ['offset' => 9, 'limit' => 4],
+      ],
+      ],
+      [0, 4, TRUE, 100, TRUE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => ['offset' => 4, 'limit' => 4],
+        'last' => ['offset' => 96, 'limit' => 4],
+      ],
+      ],
+      [0, 1, FALSE, 1, FALSE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => NULL,
+      ],
+      ],
+      [0, 1, FALSE, 2, FALSE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => NULL,
+      ],
+      ],
+    ];
+  }
+
+  /**
+   * Test errors.
+   *
+   * @covers ::getPagerLinks
+   * @dataProvider getPagerLinksErrorProvider
+   */
+  public function testGetPagerLinksError($offset, $size, $has_next_page, $total, $include_count, array $pages) {
+    $this->setExpectedException(CacheableBadRequestHttpException::class);
+    $this->testGetPagerLinks($offset, $size, $has_next_page, $total, $include_count, $pages);
+  }
+
+  /**
+   * Data provider for testGetPagerLinksError.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function getPagerLinksErrorProvider() {
+    return [
+      [0, -5, FALSE, 10, TRUE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'last' => NULL,
+        'next' => NULL,
+      ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::getRequestLink
+   */
+  public function testGetRequestLink() {
+    $assembler = $this->prophesize(UnroutedUrlAssemblerInterface::class);
+    $return = function ($query) {
+      return function ($args) use ($query) {
+        return $args[0] . $query;
+      };
+    };
+    $assembler->assemble(Argument::type('string'), ['external' => TRUE, 'query' => ['dolor' => 'sid']], FALSE)->will($return('?dolor=sid'))->shouldBeCalled();
+    $assembler->assemble(Argument::type('string'), ['external' => TRUE], FALSE)->will($return(''))->shouldBeCalled();
+
+    $container = new ContainerBuilder();
+    $container->set('unrouted_url_assembler', $assembler->reveal());
+    \Drupal::setContainer($container);
+
+    $request = $this->prophesize(Request::class);
+    $request->getUri()->willReturn('https://example.com/drupal/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72?amet=pax');
+    $request->getBaseUrl()->willReturn('/drupal');
+    $request->getPathInfo()->willReturn('');
+    $request->getSchemeAndHttpHost()->willReturn('https://example.com');
+    $request->getBaseUrl()->willReturn('/drupal');
+    $request->getPathInfo()->willReturn('/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72');
+
+    $this->assertSame('https://example.com/drupal/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72?dolor=sid', $this->linkManager->getRequestLink($request->reveal(), ['dolor' => 'sid'])->toString());
+
+    // Get the default query from the request object.
+    $this->assertSame('https://example.com/drupal/jsonapi/node/article/07c870e9-491b-4173-8e2b-4e059400af72?amet=pax', $this->linkManager->getRequestLink($request->reveal())->toString());
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
new file mode 100644
index 0000000000..b85f58e10e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class HttpExceptionNormalizerTest extends UnitTestCase {
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalize() {
+    $request_stack = $this->prophesize(RequestStack::class);
+    $request_stack->getCurrentRequest()->willReturn(Request::create('http://localhost/'));
+    $container = $this->prophesize(ContainerInterface::class);
+    $container->get('request_stack')->willReturn($request_stack->reveal());
+    \Drupal::setContainer($container->reveal());
+    $exception = new AccessDeniedHttpException('lorem', NULL, 13);
+    $current_user = $this->prophesize(AccountInterface::class);
+    $current_user->hasPermission('access site reports')->willReturn(TRUE);
+    $normalizer = new HttpExceptionNormalizer($current_user->reveal());
+    $normalized = $normalizer->normalize($exception, 'api_json');
+    $normalized = $normalized->getNormalization();
+    $error = $normalized[0];
+    $this->assertNotEmpty($error['meta']);
+    $this->assertNotEmpty($error['source']);
+    $this->assertEquals(13, $error['code']);
+    $this->assertEquals(403, $error['status']);
+    $this->assertEquals('Forbidden', $error['title']);
+    $this->assertEquals('lorem', $error['detail']);
+    $this->assertArrayHasKey('trace', $error['meta']);
+    $this->assertNotEmpty($error['meta']['trace']);
+
+    $current_user = $this->prophesize(AccountInterface::class);
+    $current_user->hasPermission('access site reports')->willReturn(FALSE);
+    $normalizer = new HttpExceptionNormalizer($current_user->reveal());
+    $normalized = $normalizer->normalize($exception, 'api_json');
+    $normalized = $normalized->getNormalization();
+    $error = $normalized[0];
+    $this->assertTrue(empty($error['meta']));
+    $this->assertTrue(empty($error['source']));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
new file mode 100644
index 0000000000..a30b1f6a18
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Serializer\SerializerInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizerTest extends UnitTestCase {
+
+  /**
+   * The normalizer under test.
+   *
+   * @var \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $field_resolver = $this->prophesize(FieldResolver::class);
+
+    $resource_type_repository
+      ->getByTypeName(Argument::any())
+      ->willReturn(new ResourceType('node', 'article', NULL));
+
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+    $self = $this;
+    $uuid_to_id = [
+      '76dd5c18-ea1b-4150-9e75-b21958a2b836' => 1,
+      'fcce1b61-258e-4054-ae36-244d25a9e04c' => 2,
+    ];
+    $entity_storage->loadByProperties(Argument::type('array'))
+      ->will(function ($args) use ($self, $uuid_to_id) {
+        $result = [];
+        foreach ($args[0]['uuid'] as $uuid) {
+          $entity = $self->prophesize(EntityInterface::class);
+          $entity->uuid()->willReturn($uuid);
+          $entity->id()->willReturn($uuid_to_id[$uuid]);
+          $result[$uuid] = $entity->reveal();
+        }
+        return $result;
+      });
+    $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
+    $entity_type_manager->getStorage('node')->willReturn($entity_storage->reveal());
+    $entity_type = $this->prophesize(EntityTypeInterface::class);
+    $entity_type->getKey('uuid')->willReturn('uuid');
+    $entity_type_manager->getDefinition('node')->willReturn($entity_type->reveal());
+
+    $this->normalizer = new JsonApiDocumentTopLevelNormalizer(
+      $entity_type_manager->reveal(),
+      $resource_type_repository->reveal(),
+      $field_resolver->reveal()
+    );
+
+    $serializer = $this->prophesize(DenormalizerInterface::class);
+    $serializer->willImplement(SerializerInterface::class);
+    $serializer->denormalize(
+      Argument::type('array'),
+      Argument::type('string'),
+      Argument::type('string'),
+      Argument::type('array')
+    )->willReturnArgument(0);
+
+    $this->normalizer->setSerializer($serializer->reveal());
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($input, $expected) {
+    $resource_type = new ResourceType('node', 'article', FieldableEntityInterface::class);
+    $resource_type->setRelatableResourceTypes([]);
+    $context = ['resource_type' => $resource_type];
+    $denormalized = $this->normalizer->denormalize($input, NULL, 'api_json', $context);
+    $this->assertSame($expected, $denormalized);
+  }
+
+  /**
+   * Data provider for the denormalize test.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function denormalizeProvider() {
+    return [
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => 'e1a613f6-f2b9-4e17-9d33-727eb6509d8b',
+            'attributes' => ['title' => 'dummy_title'],
+          ],
+        ],
+        [
+          'title' => 'dummy_title',
+          'uuid' => 'e1a613f6-f2b9-4e17-9d33-727eb6509d8b',
+        ],
+      ],
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => '0676d1bf-55b3-4bbc-9fbc-3df10f4599d5',
+            'relationships' => ['field_dummy' => ['data' => ['type' => 'node', 'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836']]],
+          ],
+        ],
+        [
+          'uuid' => '0676d1bf-55b3-4bbc-9fbc-3df10f4599d5',
+          'field_dummy' => [
+            [
+              'target_id' => 1,
+            ],
+          ],
+        ],
+      ],
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => '535ba297-8d79-4fc1-b0d6-dc2f047765a1',
+            'relationships' => [
+              'field_dummy' => [
+                'data' => [
+                  [
+                    'type' => 'node',
+                    'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836',
+                  ],
+                  [
+                    'type' => 'node',
+                    'id' => 'fcce1b61-258e-4054-ae36-244d25a9e04c',
+                  ],
+                ],
+              ],
+            ],
+          ],
+        ],
+        [
+          'uuid' => '535ba297-8d79-4fc1-b0d6-dc2f047765a1',
+          'field_dummy' => [
+            ['target_id' => 1],
+            ['target_id' => 2],
+          ],
+        ],
+      ],
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => '535ba297-8d79-4fc1-b0d6-dc2f047765a1',
+            'relationships' => [
+              'field_dummy' => [
+                'data' => [
+                  [
+                    'type' => 'node',
+                    'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836',
+                    'meta' => ['foo' => 'bar'],
+                  ],
+                  [
+                    'type' => 'node',
+                    'id' => 'fcce1b61-258e-4054-ae36-244d25a9e04c',
+                  ],
+                ],
+              ],
+            ],
+          ],
+        ],
+        [
+          'uuid' => '535ba297-8d79-4fc1-b0d6-dc2f047765a1',
+          'field_dummy' => [
+            [
+              'target_id' => 1,
+              'foo' => 'bar',
+            ],
+            ['target_id' => 2],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Ensures only valid UUIDs can be specified.
+   *
+   * @param string $id
+   *   The input UUID. May be invalid.
+   * @param bool $expect_exception
+   *   Whether to expect an exception.
+   *
+   * @covers ::denormalize
+   * @dataProvider denormalizeUuidProvider
+   */
+  public function testDenormalizeUuid($id, $expect_exception) {
+    $data['data'] = (isset($id)) ?
+      ['type' => 'node--article', 'id' => $id] :
+      ['type' => 'node--article'];
+
+    if ($expect_exception) {
+      $this->setExpectedException(
+        UnprocessableEntityHttpException::class,
+        'IDs should be properly generated and formatted UUIDs as described in RFC 4122.'
+      );
+    }
+
+    $denormalized = $this->normalizer->denormalize($data, NULL, 'api_json', [
+      'resource_type' => new ResourceType(
+        'node',
+        'article',
+        FieldableEntityInterface::class
+      ),
+    ]);
+
+    if (isset($id)) {
+      $this->assertSame($id, $denormalized['uuid']);
+    }
+    else {
+      $this->assertArrayNotHasKey('uuid', $denormalized);
+    }
+  }
+
+  /**
+   * Provides test cases for testDenormalizeUuid.
+   */
+  public function denormalizeUuidProvider() {
+    return [
+      'valid' => ['76dd5c18-ea1b-4150-9e75-b21958a2b836', FALSE],
+      'missing' => [NULL, FALSE],
+      'invalid_empty' => ['', TRUE],
+      'invalid_alpha' => ['invalid', TRUE],
+      'invalid_numeric' => [1234, TRUE],
+      'invalid_alphanumeric' => ['abc123', TRUE],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php
new file mode 100644
index 0000000000..a2af02e820
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\Normalizer\ResourceIdentifierNormalizer;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\ResourceIdentifierNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class ResourceIdentifierNormalizerTest extends UnitTestCase {
+
+  /**
+   * The normalizer under test.
+   *
+   * @var \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * The base resource type for testing.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    $target_resource_type = new ResourceType('lorem', 'dummy_bundle', NULL);
+    $this->resourceType = new ResourceType('fake_entity_type', 'dummy_bundle', NULL);
+    $this->resourceType->setRelatableResourceTypes([
+      'field_dummy' => [$target_resource_type],
+      'field_dummy_single' => [$target_resource_type],
+    ]);
+
+    $field_manager = $this->prophesize(EntityFieldManagerInterface::class);
+    $field_definition = $this->prophesize(FieldConfig::class);
+    $item_definition = $this->prophesize(FieldItemDataDefinition::class);
+    $item_definition->getMainPropertyName()->willReturn('bunny');
+    $item_definition->getSetting('target_type')->willReturn('fake_entity_type');
+    $item_definition->getSetting('handler_settings')->willReturn([
+      'target_bundles' => ['dummy_bundle'],
+    ]);
+    $field_definition->getItemDefinition()
+      ->willReturn($item_definition->reveal());
+    $storage_definition = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $storage_definition->isMultiple()->willReturn(TRUE);
+    $field_definition->getFieldStorageDefinition()->willReturn($storage_definition->reveal());
+
+    $field_definition2 = $this->prophesize(FieldConfig::class);
+    $field_definition2->getItemDefinition()
+      ->willReturn($item_definition->reveal());
+    $storage_definition2 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $storage_definition2->isMultiple()->willReturn(FALSE);
+    $field_definition2->getFieldStorageDefinition()->willReturn($storage_definition2->reveal());
+
+    $field_manager->getFieldDefinitions('fake_entity_type', 'dummy_bundle')
+      ->willReturn([
+        'field_dummy' => $field_definition->reveal(),
+        'field_dummy_single' => $field_definition2->reveal(),
+      ]);
+    $plugin_manager = $this->prophesize(FieldTypePluginManagerInterface::class);
+    $plugin_manager->createFieldItemList(
+      Argument::type(FieldableEntityInterface::class),
+      Argument::type('string'),
+      Argument::type('array')
+    )->willReturnArgument(2);
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository->get('fake_entity_type', 'dummy_bundle')->willReturn($this->resourceType);
+
+    $entity = $this->prophesize(EntityInterface::class);
+    $entity->uuid()->willReturn('4e6cb61d-4f04-437f-99fe-42c002393658');
+    $entity->id()->willReturn(42);
+    $entity_repository = $this->prophesize(EntityRepositoryInterface::class);
+    $entity_repository->loadEntityByUuid('lorem', '4e6cb61d-4f04-437f-99fe-42c002393658')
+      ->willReturn($entity->reveal());
+
+    $this->normalizer = new ResourceIdentifierNormalizer(
+      $field_manager->reveal()
+    );
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($input, $field_name, $expected) {
+    $entity = $this->prophesize(FieldableEntityInterface::class);
+    $context = [
+      'resource_type' => $this->resourceType,
+      'related' => $field_name,
+      'target_entity' => $entity->reveal(),
+    ];
+    $denormalized = $this->normalizer->denormalize($input, NULL, 'api_json', $context);
+    $this->assertEquals($expected, $denormalized);
+  }
+
+  /**
+   * Data provider for the denormalize test.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function denormalizeProvider() {
+    return [
+      [
+        ['data' => [['type' => 'lorem--dummy_bundle', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']]],
+        'field_dummy',
+        [new ResourceIdentifier('lorem--dummy_bundle', '4e6cb61d-4f04-437f-99fe-42c002393658')],
+      ],
+      [
+        ['data' => []],
+        'field_dummy',
+        [],
+      ],
+      [
+        ['data' => NULL],
+        'field_dummy_single',
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeInvalidResourceProvider
+   */
+  public function testDenormalizeInvalidResource($data, $field_name) {
+    $context = [
+      'resource_type' => $this->resourceType,
+      'related' => $field_name,
+      'target_entity' => $this->prophesize(FieldableEntityInterface::class)->reveal(),
+    ];
+    $this->setExpectedException(BadRequestHttpException::class);
+    $this->normalizer->denormalize($data, NULL, 'api_json', $context);
+  }
+
+  /**
+   * Data provider for the denormalize test.
+   *
+   * @return array
+   *   The input data for the test method.
+   */
+  public function denormalizeInvalidResourceProvider() {
+    return [
+      [
+        [
+          'data' => [
+            [
+              'type' => 'invalid',
+              'id' => '4e6cb61d-4f04-437f-99fe-42c002393658',
+            ],
+          ],
+        ],
+        'field_dummy',
+      ],
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => '4e6cb61d-4f04-437f-99fe-42c002393658',
+          ],
+        ],
+        'field_dummy',
+      ],
+      [
+        [
+          'data' => [
+            [
+              'type' => 'lorem',
+              'id' => '4e6cb61d-4f04-437f-99fe-42c002393658',
+            ],
+          ],
+        ],
+        'field_dummy_single',
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionGroupTest.php b/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionGroupTest.php
new file mode 100644
index 0000000000..67c4d8c207
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionGroupTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Query;
+
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\EntityConditionGroup
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityConditionGroupTest extends UnitTestCase {
+
+  /**
+   * @covers ::__construct
+   * @dataProvider constructProvider
+   */
+  public function testConstruct($case) {
+    $group = new EntityConditionGroup($case['conjunction'], $case['members']);
+
+    $this->assertEquals($case['conjunction'], $group->conjunction());
+
+    foreach ($group->members() as $key => $condition) {
+      $this->assertEquals($case['members'][$key]['path'], $condition->field());
+      $this->assertEquals($case['members'][$key]['value'], $condition->value());
+    }
+  }
+
+  /**
+   * @covers ::__construct
+   */
+  public function testConstructException() {
+    $this->setExpectedException(\InvalidArgumentException::class);
+    new EntityConditionGroup('NOT_ALLOWED', []);
+  }
+
+  /**
+   * Data provider for testConstruct.
+   */
+  public function constructProvider() {
+    return [
+      [['conjunction' => 'AND', 'members' => []]],
+      [['conjunction' => 'OR', 'members' => []]],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionTest.php b/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionTest.php
new file mode 100644
index 0000000000..c8b8b02bea
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Query/EntityConditionTest.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Query;
+
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\DependencyInjection\Container;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\EntityCondition
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityConditionTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $container = new Container();
+    $cache_context_manager = $this->prophesize(CacheContextsManager::class);
+    $cache_context_manager->assertValidTokens(Argument::any())
+      ->willReturn(TRUE);
+    $container->set('cache_contexts_manager', $cache_context_manager->reveal());
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   * @dataProvider queryParameterProvider
+   */
+  public function testCreateFromQueryParameter($case) {
+    $condition = EntityCondition::createFromQueryParameter($case);
+    $this->assertEquals($case['path'], $condition->field());
+    $this->assertEquals($case['value'], $condition->value());
+    if (isset($case['operator'])) {
+      $this->assertEquals($case['operator'], $condition->operator());
+    }
+  }
+
+  /**
+   * Data provider for testDenormalize.
+   */
+  public function queryParameterProvider() {
+    return [
+      [['path' => 'some_field', 'value' => NULL, 'operator' => '=']],
+      [['path' => 'some_field', 'operator' => '=', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => '<>', 'value' => 'some_string']],
+      [
+        [
+          'path' => 'some_field',
+          'operator' => 'NOT BETWEEN',
+          'value' => 'some_string',
+        ],
+      ],
+      [
+        [
+          'path' => 'some_field',
+          'operator' => 'BETWEEN',
+          'value' => ['some_string'],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::validate
+   * @dataProvider validationProvider
+   */
+  public function testValidation($input, $exception) {
+    if ($exception) {
+      $this->setExpectedException(get_class($exception), $exception->getMessage());
+    }
+    EntityCondition::createFromQueryParameter($input);
+    $this->assertTrue(is_null($exception), 'No exception was expected.');
+  }
+
+  /**
+   * Data provider for testValidation.
+   */
+  public function validationProvider() {
+    return [
+      [['path' => 'some_field', 'value' => 'some_value'], NULL],
+      [
+        ['path' => 'some_field', 'value' => 'some_value', 'operator' => '='],
+        NULL,
+      ],
+      [['path' => 'some_field', 'operator' => 'IS NULL'], NULL],
+      [['path' => 'some_field', 'operator' => 'IS NOT NULL'], NULL],
+      [
+        ['path' => 'some_field', 'operator' => 'IS', 'value' => 'some_value'],
+        new BadRequestHttpException("The 'IS' operator is not allowed in a filter parameter."),
+      ],
+      [
+        [
+          'path' => 'some_field',
+          'operator' => 'NOT_ALLOWED',
+          'value' => 'some_value',
+        ],
+        new BadRequestHttpException("The 'NOT_ALLOWED' operator is not allowed in a filter parameter."),
+      ],
+      [
+        [
+          'path' => 'some_field',
+          'operator' => 'IS NULL',
+          'value' => 'should_not_be_here',
+        ],
+        new BadRequestHttpException("Filters using the 'IS NULL' operator should not provide a value."),
+      ],
+      [
+        [
+          'path' => 'some_field',
+          'operator' => 'IS NOT NULL',
+          'value' => 'should_not_be_here',
+        ],
+        new BadRequestHttpException("Filters using the 'IS NOT NULL' operator should not provide a value."),
+      ],
+      [
+        ['path' => 'path_only'],
+        new BadRequestHttpException("Filter parameter is missing a '" . EntityCondition::VALUE_KEY . "' key."),
+      ],
+      [
+        ['value' => 'value_only'],
+        new BadRequestHttpException("Filter parameter is missing a '" . EntityCondition::PATH_KEY . "' key."),
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Query/OffsetPageTest.php b/core/modules/jsonapi/tests/src/Unit/Query/OffsetPageTest.php
new file mode 100644
index 0000000000..5e9287e7fe
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Query/OffsetPageTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Query;
+
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\DependencyInjection\Container;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\OffsetPage
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class OffsetPageTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $container = new Container();
+    $cache_context_manager = $this->prophesize(CacheContextsManager::class);
+    $cache_context_manager->assertValidTokens(Argument::any())
+      ->willReturn(TRUE);
+    $container->set('cache_contexts_manager', $cache_context_manager->reveal());
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   * @dataProvider parameterProvider
+   */
+  public function testCreateFromQueryParameter($original, $expected) {
+    $actual = OffsetPage::createFromQueryParameter($original);
+    $this->assertEquals($expected['offset'], $actual->getOffset());
+    $this->assertEquals($expected['limit'], $actual->getSize());
+  }
+
+  /**
+   * Data provider for testCreateFromQueryParameter.
+   */
+  public function parameterProvider() {
+    return [
+      [['offset' => 12, 'limit' => 20], ['offset' => 12, 'limit' => 20]],
+      [['offset' => 12, 'limit' => 60], ['offset' => 12, 'limit' => 50]],
+      [['offset' => 12], ['offset' => 12, 'limit' => 50]],
+      [['offset' => 0], ['offset' => 0, 'limit' => 50]],
+      [[], ['offset' => 0, 'limit' => 50]],
+    ];
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   */
+  public function testCreateFromQueryParameterFail() {
+    $this->setExpectedException(BadRequestHttpException::class);
+    OffsetPage::createFromQueryParameter('lorem');
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Query/SortTest.php b/core/modules/jsonapi/tests/src/Unit/Query/SortTest.php
new file mode 100644
index 0000000000..c97a562c11
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Query/SortTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Query;
+
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\DependencyInjection\Container;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\Sort
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class SortTest extends UnitTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $container = new Container();
+    $cache_context_manager = $this->prophesize(CacheContextsManager::class);
+    $cache_context_manager->assertValidTokens(Argument::any())
+      ->willReturn(TRUE);
+    $container->set('cache_contexts_manager', $cache_context_manager->reveal());
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   * @dataProvider parameterProvider
+   */
+  public function testCreateFromQueryParameter($input, $expected) {
+    $sort = Sort::createFromQueryParameter($input);
+    foreach ($sort->fields() as $index => $sort_field) {
+      $this->assertEquals($expected[$index]['path'], $sort_field['path']);
+      $this->assertEquals($expected[$index]['direction'], $sort_field['direction']);
+      $this->assertEquals($expected[$index]['langcode'], $sort_field['langcode']);
+    }
+  }
+
+  /**
+   * Provides a suite of shortcut sort paramaters and their expected expansions.
+   */
+  public function parameterProvider() {
+    return [
+      ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
+      [
+        '-lorem',
+        [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]],
+      ],
+      ['-lorem,ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL],
+      ],
+      ],
+      ['-lorem,-ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL],
+      ],
+      ],
+      [[
+        ['path' => 'lorem', 'langcode' => NULL],
+        ['path' => 'ipsum', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ], [
+        ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::createFromQueryParameter
+   * @dataProvider badParameterProvider
+   */
+  public function testCreateFromQueryParameterFail($input) {
+    $this->setExpectedException(BadRequestHttpException::class);
+    Sort::createFromQueryParameter($input);
+  }
+
+  /**
+   * Data provider for testCreateFromQueryParameterFail.
+   */
+  public function badParameterProvider() {
+    return [
+      [[['lorem']]],
+      [''],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
new file mode 100644
index 0000000000..b423262dbf
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
@@ -0,0 +1,242 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Routing\Routes;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\Routes
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RoutesTest extends UnitTestCase {
+
+  /**
+   * List of routes objects for the different scenarios.
+   *
+   * @var \Drupal\jsonapi\Routing\Routes[]
+   */
+  protected $routes;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $type_1 = new ResourceType('entity_type_1', 'bundle_1_1', EntityInterface::class);
+    $type_2 = new ResourceType('entity_type_2', 'bundle_2_1', EntityInterface::class, TRUE);
+    $relatable_resource_types = [
+      'external' => [$type_1],
+      'internal' => [$type_2],
+      'both' => [$type_1, $type_2],
+    ];
+    $type_1->setRelatableResourceTypes($relatable_resource_types);
+    $type_2->setRelatableResourceTypes($relatable_resource_types);
+    // This type ensures that we can create routes for bundle IDs which might be
+    // cast from strings to integers.  It should not affect related resource
+    // routing.
+    $type_3 = new ResourceType('entity_type_3', '123', EntityInterface::class, TRUE);
+    $type_3->setRelatableResourceTypes([]);
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository->all()->willReturn([$type_1, $type_2, $type_3]);
+    $container = $this->prophesize(ContainerInterface::class);
+    $container->get('jsonapi.resource_type.repository')->willReturn($resource_type_repository->reveal());
+    $container->getParameter('jsonapi.base_path')->willReturn('/jsonapi');
+    $container->getParameter('authentication_providers')->willReturn([
+      'lorem' => [],
+      'ipsum' => [],
+    ]);
+
+    $this->routes['ok'] = Routes::create($container->reveal());
+  }
+
+  /**
+   * @covers ::routes
+   */
+  public function testRoutesCollection() {
+    // Get the route collection and start making assertions.
+    $routes = $this->routes['ok']->routes();
+
+    // - 2 collection routes; GET & POST for the non-internal resource type.
+    // - 3 individual routes; GET, PATCH & DELETE for the non-internal resource
+    //   type.
+    // - 2 related routes; GET for the non-internal resource type relationships
+    //   fields: external & both.
+    // - 12 relationship routes; 3 fields * 4 HTTP methods.
+    //   `relationship` routes are generated even for internal target resource
+    //   types (`related` routes are not).
+    // - 1 for the JSON:API entry point.
+    $this->assertEquals(20, $routes->count());
+
+    $iterator = $routes->getIterator();
+    // Check the collection route.
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.collection');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1', $route->getPath());
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['GET'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':getCollection', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    // Check the collection POST route.
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.collection.post');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1', $route->getPath());
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['POST'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':createIndividual', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame('Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel', $route->getDefault('serialization_class'));
+  }
+
+  /**
+   * @covers ::routes
+   */
+  public function testRoutesIndividual() {
+    // Get the route collection and start making assertions.
+    $iterator = $this->routes['ok']->routes()->getIterator();
+
+    // Check the individual route.
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.individual');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity}', $route->getPath());
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['GET'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':getIndividual', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals([
+      'entity' => ['type' => 'entity:entity_type_1'],
+      'resource_type' => ['type' => 'jsonapi_resource_type'],
+    ], $route->getOption('parameters'));
+
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.individual.patch');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity}', $route->getPath());
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['PATCH'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':patchIndividual', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(JsonApiDocumentTopLevel::class, $route->getDefault('serialization_class'));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals([
+      'entity' => ['type' => 'entity:entity_type_1'],
+      'resource_type' => ['type' => 'jsonapi_resource_type'],
+    ], $route->getOption('parameters'));
+
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.individual.delete');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity}', $route->getPath());
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['DELETE'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':deleteIndividual', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals([
+      'entity' => ['type' => 'entity:entity_type_1'],
+      'resource_type' => ['type' => 'jsonapi_resource_type'],
+    ], $route->getOption('parameters'));
+  }
+
+  /**
+   * @covers ::routes
+   */
+  public function testRoutesRelated() {
+    // Get the route collection and start making assertions.
+    $iterator = $this->routes['ok']->routes()->getIterator();
+
+    // Check the related route.
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.external.related');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity}/external', $route->getPath());
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['GET'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':getRelated', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals([
+      'entity' => ['type' => 'entity:entity_type_1'],
+      'resource_type' => ['type' => 'jsonapi_resource_type'],
+    ], $route->getOption('parameters'));
+  }
+
+  /**
+   * @covers ::routes
+   */
+  public function testRoutesRelationships() {
+    // Get the route collection and start making assertions.
+    $iterator = $this->routes['ok']->routes()->getIterator();
+
+    // Check the relationships route.
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.both.relationship.get');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity}/relationships/both', $route->getPath());
+    $this->assertSame('entity_type_1--bundle_1_1', $route->getDefault(Routes::RESOURCE_TYPE_KEY));
+    $this->assertEquals(['GET'], $route->getMethods());
+    $this->assertSame(Routes::CONTROLLER_SERVICE_NAME . ':getRelationship', $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals([
+      'entity' => ['type' => 'entity:entity_type_1'],
+      'resource_type' => ['type' => 'jsonapi_resource_type'],
+    ], $route->getOption('parameters'));
+    $this->assertSame(ResourceIdentifier::class, $route->getDefault('serialization_class'));
+  }
+
+  /**
+   * Ensures that the expected routes are created or not created.
+   *
+   * @dataProvider expectedRoutes
+   */
+  public function testRoutes($route) {
+    $this->assertArrayHasKey($route, $this->routes['ok']->routes()->all());
+  }
+
+  /**
+   * Lists routes which should have been created.
+   */
+  public function expectedRoutes() {
+    return [
+      ['jsonapi.entity_type_1--bundle_1_1.individual'],
+      ['jsonapi.entity_type_1--bundle_1_1.collection'],
+      ['jsonapi.entity_type_1--bundle_1_1.internal.relationship.get'],
+      ['jsonapi.entity_type_1--bundle_1_1.internal.relationship.post'],
+      ['jsonapi.entity_type_1--bundle_1_1.internal.relationship.patch'],
+      ['jsonapi.entity_type_1--bundle_1_1.internal.relationship.delete'],
+      ['jsonapi.entity_type_1--bundle_1_1.external.related'],
+      ['jsonapi.entity_type_1--bundle_1_1.external.relationship.get'],
+      ['jsonapi.entity_type_1--bundle_1_1.external.relationship.post'],
+      ['jsonapi.entity_type_1--bundle_1_1.external.relationship.patch'],
+      ['jsonapi.entity_type_1--bundle_1_1.external.relationship.delete'],
+      ['jsonapi.entity_type_1--bundle_1_1.both.related'],
+      ['jsonapi.entity_type_1--bundle_1_1.both.relationship.get'],
+      ['jsonapi.entity_type_1--bundle_1_1.both.relationship.post'],
+      ['jsonapi.entity_type_1--bundle_1_1.both.relationship.patch'],
+      ['jsonapi.entity_type_1--bundle_1_1.both.relationship.delete'],
+      ['jsonapi.resource_list'],
+    ];
+  }
+
+  /**
+   * Ensures that no routes are created for internal resources.
+   *
+   * @dataProvider notExpectedRoutes
+   */
+  public function testInternalRoutes($route) {
+    $this->assertArrayNotHasKey($route, $this->routes['ok']->routes()->all());
+  }
+
+  /**
+   * Lists routes which should have been created.
+   */
+  public function notExpectedRoutes() {
+    return [
+      ['jsonapi.entity_type_2--bundle_2_1.individual'],
+      ['jsonapi.entity_type_2--bundle_2_1.collection'],
+      ['jsonapi.entity_type_2--bundle_2_1.collection.post'],
+      ['jsonapi.entity_type_2--bundle_2_1.internal.related'],
+      ['jsonapi.entity_type_2--bundle_2_1.internal.relationship'],
+    ];
+  }
+
+}
