diff --git a/core/composer.json b/core/composer.json
index 8dfd8a1..7095b2a 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -57,6 +57,7 @@
         "drupal/coder": "^8.2.12",
         "jcalderonzumba/gastonjs": "^1.0.2",
         "jcalderonzumba/mink-phantomjs-driver": "^0.3.1",
+        "justinrainbow/json-schema": "^5.2",
         "mikey179/vfsStream": "^1.2",
         "phpunit/phpunit": "^4.8.35 || ^6.5",
         "phpspec/prophecy": "^1.7",
@@ -127,6 +128,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 0000000..c4d5395
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.api.php
@@ -0,0 +1,294 @@
+<?php
+
+/**
+ * @file
+ * Documentation related to JSON:API.
+ */
+
+use Drupal\Core\Access\AccessResult;
+
+/**
+ * @defgroup jsonapi_normalizer_architecture JSON:API Normalizer 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 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 0000000..9e77ac2
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.info.yml
@@ -0,0 +1,9 @@
+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
+test_dependencies:
+  - schemata:schemata_json_schema
diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module
new file mode 100644
index 0000000..f1256bf
--- /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 0000000..a58e841
--- /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 0000000..b1574a2
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.services.yml
@@ -0,0 +1,206 @@
+parameters:
+  jsonapi.base_path: /jsonapi
+
+services:
+  jsonapi.serializer_do_not_use_removal_imminent:
+    class: Drupal\jsonapi\Serializer\Serializer
+    calls:
+      - [setFallbackNormalizer, ['@serializer']]
+    arguments: [{  }, {  }]
+  serializer.normalizer.sort.jsonapi:
+    class: Drupal\jsonapi\Normalizer\SortNormalizer
+    arguments: ['@jsonapi.field_resolver']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.offset_page.jsonapi:
+    class: Drupal\jsonapi\Normalizer\OffsetPageNormalizer
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity_condition.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityConditionNormalizer
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity_condition_group.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.filter.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FilterNormalizer
+    arguments: ['@jsonapi.field_resolver', '@serializer.normalizer.entity_condition.jsonapi', '@serializer.normalizer.entity_condition_group.jsonapi']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.http_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  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_do_not_use_removal_imminent, 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_do_not_use_removal_imminent, priority: 1 }
+  serializer.normalizer.entity_reference_item.jsonapi:
+    class: Drupal\jsonapi\Normalizer\RelationshipItemNormalizer
+    arguments: ['@jsonapi.resource_type.repository']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.field_item.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldItemNormalizer
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldNormalizer
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.relationship.jsonapi:
+    class: Drupal\jsonapi\Normalizer\RelationshipNormalizer
+    arguments: ['@jsonapi.resource_type.repository', '@jsonapi.link_manager', '@entity_field.manager', '@entity.repository']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity.label_only.jsonapi:
+    class: Drupal\jsonapi\Normalizer\LabelOnlyEntityNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.config_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.jsonapi_document_toplevel.jsonapi:
+    class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+    arguments: ['@jsonapi.link_manager', '@entity_type.manager', '@jsonapi.resource_type.repository']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity_reference_field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+    arguments: ['@jsonapi.resource_type.repository', '@entity.repository']
+    tags:
+      # This must have a higher priority than the 'serializer.normalizer.field.jsonapi' to take effect.
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent, priority: 1 }
+  serializer.encoder.jsonapi:
+    class: Drupal\jsonapi\Encoder\JsonEncoder
+    tags:
+      - { name: encoder, priority: 21, 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.params.enhancer:
+    class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
+    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']
+  access_check.jsonapi.relationship_field_access:
+    class: Drupal\jsonapi\Access\RelationshipFieldAccess
+    tags:
+      - { name: access_check, applies_to: _jsonapi_relationship_field_access, needs_incoming_request: TRUE }
+  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_do_not_use_removal_imminent', '%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 }]
+
+  # Middleware.
+  jsonapi.http_middleware.format_setter:
+    class: Drupal\jsonapi\StackMiddleware\FormatSetter
+    tags:
+      # Set priority to 201 so it happens right before the page cache
+      # middleware (priority 200) has the opportunity to respond.
+      - { name: http_middleware, priority: 201 }
+
+  # 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'
+
+  # 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_do_not_use_removal_imminent']
+    tags:
+      - { name: event_subscriber }
+  jsonapi.resource_response_validator.subscriber:
+    class: Drupal\jsonapi\EventSubscriber\ResourceResponseValidator
+    arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '@logger.channel.jsonapi', '@module_handler', '@app.root']
+    calls:
+      - [setValidator, []]
+      - [setSchemaFactory, ['@?schemata.schema_factory']] # This is only injected when the service is available.
+    tags:
+      - { name: event_subscriber, priority: 1000 }
+
+  # 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 }
diff --git a/core/modules/jsonapi/schema.json b/core/modules/jsonapi/schema.json
new file mode 100644
index 0000000..902a39d
--- /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/RelationshipFieldAccess.php b/core/modules/jsonapi/src/Access/RelationshipFieldAccess.php
new file mode 100644
index 0000000..5c9e477
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/RelationshipFieldAccess.php
@@ -0,0 +1,71 @@
+<?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';
+
+  /**
+   * 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 = $entity->access($entity_operation, $account, TRUE);
+        $field_access = $entity->get($internal_name)->access($field_operation, $account, TRUE);
+        $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 0000000..8150350
--- /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 0000000..9a6efb6
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/FieldResolver.php
@@ -0,0 +1,777 @@
+<?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.
+        // @todo: to provide a better DX, we should actually validate that the
+        // remaining parts are in fact valid properties.
+        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 on 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 0000000..15e2ff6
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -0,0 +1,1103 @@
+<?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\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\Access\TemporaryQueryGuard;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\IncludeResolver;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\LabelOnlyEntity;
+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\Routing\Routes;
+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 {
+
+  /**
+   * 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;
+
+  /**
+   * 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.
+   */
+  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) {
+    $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;
+  }
+
+  /**
+   * 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) {
+    $entity = static::getAccessCheckedEntity($entity);
+    if ($entity instanceof EntityAccessDeniedHttpException) {
+      throw $entity;
+    }
+    $response = $this->buildWrappedResponse($entity, $request, $this->getIncludes($request, $entity));
+    return $response;
+  }
+
+  /**
+   * Verifies that the whole 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 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;
+    }
+  }
+
+  /**
+   * 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.
+   */
+  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());
+      if (isset($document['data']['attributes'])) {
+        $received_attributes = array_keys($document['data']['attributes']);
+        foreach ($received_attributes as $field_name) {
+          $internal_field_name = $resource_type->getInternalName($field_name);
+          $field_access = $parsed_entity->get($internal_field_name)
+            ->access('edit', NULL, TRUE);
+          if (!$field_access->isAllowed()) {
+            throw new EntityAccessDeniedHttpException(NULL, $field_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to POST the selected field (%s).', $field_name));
+          }
+        }
+      }
+      if (isset($document['data']['relationships'])) {
+        $received_relationships = array_keys($document['data']['relationships']);
+        foreach ($received_relationships as $field_name) {
+          $internal_field_name = $resource_type->getInternalName($field_name);
+          $field_access = $parsed_entity->get($internal_field_name)->access('edit', NULL, TRUE);
+          if (!$field_access->isAllowed()) {
+            throw new EntityAccessDeniedHttpException(NULL, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to POST the selected field (%s).', $field_name));
+          }
+        }
+      }
+    }
+
+    $this->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.
+    $response = $this->buildWrappedResponse($parsed_entity, $request, new NullEntityCollection(), 201);
+
+    // According to JSON:API specification, when a new entity was created
+    // we should send "Location" header to the frontend.
+    $entity_url = $this->linkManager->getEntityLink(
+      $parsed_entity->uuid(),
+      $resource_type,
+      [],
+      'individual'
+    );
+    if ($entity_url) {
+      $response->headers->set('Location', $entity_url);
+    }
+
+    // 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.
+   */
+  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);
+
+    $this->validate($entity, $field_names);
+    $entity->save();
+    return $this->buildWrappedResponse($entity, $request, new NullEntityCollection());
+  }
+
+  /**
+   * 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 = static::getJsonApiParams($request, $resource_type);
+    $query_cacheability = new CacheableMetadata();
+    $query = $this->getCollectionQuery($resource_type, $params, $query_cacheability);
+
+    try {
+      $results = $this->executeQueryInRenderContext(
+        $query,
+        $query_cacheability
+      );
+    }
+    catch (\LogicException $e) {
+      // Ensure good DX when an entity query involves a config entity type.
+      // @todo Core should throw a better exception.
+      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);
+    $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);
+
+    $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',
+      ]));
+
+    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 when the query sytems's return value is able to carry
+   * cacheability.
+   */
+  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[] = static::getAccessCheckedEntity($referenced_entity);
+    }
+    $entity_collection = new EntityCollection($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality());
+    $response = $this->buildWrappedResponse($entity_collection, $request, $this->getIncludes($request, $entity_collection, $related));
+
+    // $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));
+    $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $entity), $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.
+   */
+  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::toResourceIdentifiers($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::toResourceIdentifiers($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.
+   */
+  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::toResourceIdentifiers($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::toResourceIdentifiers($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.
+    $this->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 = $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 string[] $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 = [], array $links = [], array $meta = []) {
+    $links['self']['href'] = $this->linkManager->getRequestLink($request);
+    return new ResourceResponse(new JsonApiDocumentTopLevel($data, $includes, $links, $meta), $response_code, $headers);
+  }
+
+  /**
+   * 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.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  protected function respondWithCollection(EntityCollection $entity_collection, EntityCollection $includes, Request $request, ResourceType $resource_type) {
+    $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(), $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.
+   * @param string $related
+   *   (optional) The relationship field name to be given for getting includes
+   *   on a related route.
+   *
+   * @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, $related = NULL) {
+    return $request->query->has('include') && ($include_parameter = $request->query->get('include')) && !empty($include_parameter)
+      ? $this->includeResolver->resolve($request->get(Routes::RESOURCE_TYPE_KEY), $data, $include_parameter, $related)
+      : 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
+   *   Array of entity IDs.
+   *
+   * @return array
+   *   An array of loaded entities and/or an access exceptions.
+   */
+  protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids) {
+    $output = [];
+    foreach ($storage->loadMultiple($ids) as $entity) {
+      $output[$entity->id()] = static::getAccessCheckedEntity($entity);
+    }
+    return array_values($output);
+  }
+
+  /**
+   * 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.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   The loaded entity, a label only version of that entity or an
+   *   EntityAccessDeniedHttpException object if neither is accessible. All
+   *   three possible return values carry the access result cacheability.
+   */
+  public static function getAccessCheckedEntity(EntityInterface $entity) {
+    /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+    $entity_repository = \Drupal::service('entity.repository');
+    $entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
+    $access = $entity->access('view', NULL, TRUE);
+    $entity->addCacheableDependency($access);
+    if (!$access->isAllowed()) {
+      $label_access = $entity->access('view label', NULL, TRUE);
+      $entity->addCacheableDependency($label_access);
+      if ($label_access->isAllowed()) {
+        return new LabelOnlyEntity($entity);
+      }
+      else {
+        // Pass an exception to the list of things to normalize.
+        return new EntityAccessDeniedHttpException($entity, $access->orIf($label_access), '/data', 'The current user is not allowed to GET the selected resource.');
+      }
+    }
+
+    return $entity;
+  }
+
+  /**
+   * 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 current JSON:API resoure type.
+   *
+   * @return array
+   *   An array of JSON:API parameters like `sort` and `filter`.
+   */
+  protected static function getJsonApiParams(Request $request, ResourceType $resource_type) {
+    $route_params = $request->attributes->get('_route_params');
+    $params = isset($route_params['_json_api_params']) ? $route_params['_json_api_params'] : [];
+    if ($request->query->has('filter')) {
+      $serializer = \Drupal::service('jsonapi.serializer_do_not_use_removal_imminent');
+      $context = ['entity_type_id' => $resource_type->getEntityTypeId(), 'bundle' => $resource_type->getBundle()];
+      $params[Filter::KEY_NAME] = $serializer->denormalize($request->query->get('filter'), Filter::class, NULL, $context);
+    }
+    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 0000000..e317782
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntryPoint.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+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 renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * 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\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The current user.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RendererInterface $renderer, AccountInterface $user) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->renderer = $renderer;
+    $this->user = $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('jsonapi.resource_type.repository'),
+      $container->get('renderer'),
+      $container->get('current_user')
+    );
+  }
+
+  /**
+   * Controller to list all the resources.
+   *
+   * @return \Drupal\Core\Cache\CacheableJsonResponse
+   *   The response object.
+   */
+  public function index() {
+    $cacheability = (new CacheableMetadata())
+      ->addCacheContexts(['user.roles:authenticated'])
+      ->addCacheTags(['jsonapi_resource_types']);
+
+    // Execute the request in context so the cacheable metadata from the entity
+    // grants system is caught and added to the response. This is surfaced when
+    // executing the underlying entity query.
+    $context = new RenderContext();
+    /** @var \Drupal\Core\Cache\CacheableResponseInterface $response */
+    $do_build_urls = function () {
+      $self = Url::fromRoute('jsonapi.resource_list')->setAbsolute();
+
+      // Only build URLs for exposed resources.
+      $resources = array_filter($this->resourceTypeRepository->all(), function ($resource) {
+        return !$resource->isInternal();
+      });
+
+      return array_reduce($resources, function (array $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();
+          $carry[$resource_type->getTypeName()] = ['href' => $url->toString()];
+        }
+        return $carry;
+      }, ['self' => ['href' => $self->toString()]]);
+    };
+    $urls = $this->renderer->executeInRenderContext($context, $do_build_urls);
+    if (!$context->isEmpty()) {
+      $cacheability = $cacheability->merge($context->pop());
+    }
+
+    $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/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php b/core/modules/jsonapi/src/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php
new file mode 100644
index 0000000..7ea4655
--- /dev/null
+++ b/core/modules/jsonapi/src/DependencyInjection/Compiler/RegisterSerializationClassesCompilerPass.php
@@ -0,0 +1,101 @@
+<?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_do_not_use_removal_imminent' will be
+ * added to the JSON:API serializer. As should be clear by the service tag,
+ * *no* extensions should provide these services. They will not work in the
+ * future. The proper way to affect JSON:API output is to implement DataType
+ * level normalizers and/or implement computed entity fields.
+ *
+ * @see jsonapi.api.php
+ *
+ * @internal
+ */
+class RegisterSerializationClassesCompilerPass extends DrupalRegisterSerializationClassesCompilerPass {
+
+  /**
+   * The service ID.
+   *
+   * @const string
+   */
+  const OVERRIDDEN_SERVICE_ID = 'jsonapi.serializer_do_not_use_removal_imminent';
+
+  /**
+   * The service tag that only JSON:API normalizers should use.
+   *
+   * @const string
+   */
+  const OVERRIDDEN_SERVICE_TAG = 'jsonapi_normalizer_do_not_use_removal_imminent';
+
+  /**
+   * 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_do_not_use_removal_imminent'
+   * Tag: 'normalizer' -> 'jsonapi_normalizer_do_not_use_removal_imminent'
+   *
+   * @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_TAG) as $id => $attributes) {
+      // Normalizers are not an API: mark private.
+      $container->getDefinition($id)->setPublic(FALSE);
+
+      // If there is a BC key present, pass this to determine if the normalizer
+      // should be skipped.
+      if (isset($attributes[0]['bc']) && $this->normalizerBcSettingIsEnabled($attributes[0]['bc'], $attributes[0]['bc_config_name'])) {
+        continue;
+      }
+
+      $priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
+      $normalizers[$priority][] = new Reference($id);
+    }
+    foreach ($container->findTaggedServiceIds('encoder') 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));
+    }
+
+    // Set the JSON:API format and format_provider.
+    $container->setParameter(
+      static::OVERRIDDEN_SERVICE_ID . '.formats',
+      [static::FORMAT]
+    );
+    $container->setParameter(
+      static::OVERRIDDEN_SERVICE_ID . '.format_providers',
+      [static::FORMAT => 'jsonapi']
+    );
+  }
+
+}
diff --git a/core/modules/jsonapi/src/DependencyInjection/Compiler/RemoveJsonapiFormatCompilerPass.php b/core/modules/jsonapi/src/DependencyInjection/Compiler/RemoveJsonapiFormatCompilerPass.php
new file mode 100644
index 0000000..2221f8c
--- /dev/null
+++ b/core/modules/jsonapi/src/DependencyInjection/Compiler/RemoveJsonapiFormatCompilerPass.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\jsonapi\DependencyInjection\Compiler;
+
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+
+/**
+ * Removes 'api_json' format from the 'serializer.formats' container parameter.
+ *
+ * We want the 'api_json' format to not be supported in the REST module. But the
+ * JSON:API module also should not have to define al alternative 'serializer'
+ * service.
+ * This is achieved through removing the 'api_json' format from the
+ * 'serializer.formats' container parameter. The consequences of doing that:
+ *
+ * - the REST module no longer allows this format to be used
+ * - the 'serialization.exception.default' service does not support 'api_json',
+ *   hence a custom exception subscriber is needed, which this module has:
+ *   'jsonapi.exception_subscriber'
+ * - the 'serializer' service does support 'api_json'
+ *
+ * In other words: the 'serializer' service supports 'api_json', but nothing is
+ * aware of it. You could only know by calling 'serializer:supportsEncoding()'.
+ *
+ * @see \Drupal\serialization\RegisterSerializationClassesCompilerPass
+ * @see \Drupal\jsonapi\JsonapiServiceProvider::register()
+ * @see \Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber
+ * @see \Drupal\Tests\jsonapi\Functional\RestJsonApiUnsupported
+ *
+ * @internal
+ */
+class RemoveJsonapiFormatCompilerPass implements CompilerPassInterface {
+
+  /**
+   * Updates the 'serializer.formats' container parameter.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+   *   The container to process.
+   */
+  public function process(ContainerBuilder $container) {
+    if ($container->hasParameter('serializer.formats')) {
+      $filtered_formats = array_filter(
+        $container->getParameter('serializer.formats'),
+        function ($format) {
+          return $format !== 'api_json';
+        }
+      );
+      $container->setParameter('serializer.formats', array_values($filtered_formats));
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Encoder/JsonEncoder.php b/core/modules/jsonapi/src/Encoder/JsonEncoder.php
new file mode 100644
index 0000000..b90168e
--- /dev/null
+++ b/core/modules/jsonapi/src/Encoder/JsonEncoder.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\jsonapi\Encoder;
+
+use Drupal\jsonapi\Normalizer\Value\ValueExtractorInterface;
+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'];
+
+  /**
+   * {@inheritdoc}
+   *
+   * @see http://jsonapi.org/format/#errors
+   */
+  public function encode($data, $format, array $context = []) {
+    // Make sure that any auto-normalizable object gets normalized before
+    // encoding. This is specially important to generate the errors in partial
+    // success responses.
+    if ($data instanceof ValueExtractorInterface) {
+      $data = $data->rasterizeValue();
+    }
+    return parent::encode($data, $format, $context);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
new file mode 100644
index 0000000..76512d9
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+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(), []), $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 0000000..0f802f7
--- /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 0000000..c5b2275
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Core\Cache\CacheableResponse;
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+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_do_not_use_removal_imminent 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 JsonApiDocumentTopLevelNormalizerValue value 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 JsonApiDocumentTopLevelNormalizerValue);
+      $response->addCacheableDependency($jsonapi_doc_object);
+      // Finally, encode the normalized data (JSON:API's encoder rasterizes it
+      // automatically).
+      $response->setContent($serializer->encode($jsonapi_doc_object, $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) {
+    $resource_type = $request->get('resource_type');
+    assert($resource_type instanceof ResourceType || $resource_type === NULL);
+
+    // Build the expanded context.
+    $context = [
+      'account' => NULL,
+      'sparse_fieldset' => NULL,
+      'resource_type' => $resource_type,
+    ];
+    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 0000000..6823498
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseValidator.php
@@ -0,0 +1,258 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\Routing\Routes;
+use Drupal\schemata\SchemaFactory;
+use JsonSchema\Validator;
+use Psr\Log\LoggerInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+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 schemata schema factory.
+   *
+   * This property will only be set if the schemata module is installed.
+   *
+   * @var \Drupal\schemata\SchemaFactory|null
+   */
+  protected $schemaFactory;
+
+  /**
+   * 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();
+    }
+  }
+
+  /**
+   * Injects the schema factory.
+   *
+   * @param \Drupal\schemata\SchemaFactory $schema_factory
+   *   The schema factory service.
+   */
+  public function setSchemaFactory(SchemaFactory $schema_factory) {
+    $this->schemaFactory = $schema_factory;
+  }
+
+  /**
+   * 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];
+    $is_valid = $this->validateSchema($generic_jsonapi_schema, $response_data);
+    if (!$is_valid) {
+      return FALSE;
+    }
+
+    // This will be set if the schemata module is present.
+    if (!$this->schemaFactory) {
+      // Fall back the valid generic result since schemata is absent.
+      return TRUE;
+    }
+
+    // Get the schema for the current resource. For that we will need to
+    // introspect the request to find the entity type and bundle matched by the
+    // router.
+    $resource_type = $request->get(Routes::RESOURCE_TYPE_KEY);
+    $route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME);
+
+    // We shouldn't validate related/relationships.
+    $is_related = strpos($route_name, '.related') !== FALSE;
+    $is_relationship = strpos($route_name, '.relationship') !== FALSE;
+    if ($is_related || $is_relationship) {
+      // Fall back the valid generic result since schemata is absent.
+      return TRUE;
+    }
+
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $bundle = $resource_type->getBundle();
+    $output_format = 'schema_json';
+    $described_format = 'api_json';
+
+    $schema_object = $this->schemaFactory->create($entity_type_id, $bundle);
+    $format = $output_format . ':' . $described_format;
+    $output = $this->serializer->serialize($schema_object, $format);
+    $specific_schema = Json::decode($output);
+    if (!$specific_schema) {
+      return $is_valid;
+    }
+
+    // We need to individually validate each collection resource object.
+    $is_collection = strpos($route_name, '.collection') !== FALSE;
+
+    // Iterate over each resource object and check the schema.
+    return array_reduce(
+      $is_collection ? $response_data->data : [$response_data->data],
+      function ($valid, $resource_object) use ($specific_schema) {
+        // Validating the schema first ensures that every object is processed.
+        return $this->validateSchema($specific_schema, $resource_object) && $valid;
+      },
+      TRUE
+    );
+  }
+
+  /**
+   * 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 0000000..fc2cfe3
--- /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 0000000..495f974
--- /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/Normalizer/DateTimeIso8601Normalizer.php b/core/modules/jsonapi/src/ForwardCompatibility/Normalizer/DateTimeIso8601Normalizer.php
new file mode 100644
index 0000000..0eedae8
--- /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 Drupal 8.7.
+ */
+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 0000000..e1584e6
--- /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 Drupal 8.7.
+ */
+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 0000000..2f3cd44
--- /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 Drupal 8.7.
+ */
+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 0000000..9558ec9
--- /dev/null
+++ b/core/modules/jsonapi/src/IncludeResolver.php
@@ -0,0 +1,238 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Controller\EntityResource;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+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;
+
+  /**
+   * IncludeResolver constructor.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * Resolves included resources.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $base_resource_type
+   *   The base resource type for which includes are to be resolved.
+   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
+   *   The resource(s) for which to resolve includes.
+   * @param string $include_parameter
+   *   The include query parameter to resolve.
+   * @param string|null $related_field
+   *   A related field if the includes should be resolved for a related route.
+   *
+   * @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(ResourceType $base_resource_type, $data, $include_parameter, $related_field = NULL) {
+    // Map a single entity into an EntityCollection.
+    $entity_collection = $data instanceof EntityInterface ? new EntityCollection([$data], 1) : $data;
+    $include_tree = static::toIncludeTree($base_resource_type, $include_parameter, $related_field);
+    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 $entity) {
+        // Some entities in the collection may be LabelOnlyEntity objects or
+        // EntityAccessDeniedHttpException objects, or they may be entities
+        // which do not have fields and cannot have relationships.
+        if ($entity instanceof LabelOnlyEntity) {
+          $message = "The current user is not allowed to view this relationship.";
+          $exception = new EntityAccessDeniedHttpException($entity->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 (!$entity instanceof FieldableEntityInterface) {
+          continue;
+        }
+        // Not all entities in $entity_collection will be of the same bundle and
+        // may not have all of the same fields. Therefore, calling
+        // $entity->get($a_missing_field_name) will result in an exception.
+        if (!$entity->hasField($field_name)) {
+          continue;
+        }
+        $field_list = $entity->get($field_name);
+        // @todo: raise an omitted item to an inaccessible related field in https://www.drupal.org/project/jsonapi/issues/2956084.
+        $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($entity, $field_access, '', $message, $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 EntityResource::getAccessCheckedEntity($entity);
+        }, $targeted_entities);
+        $targeted_collection = new EntityCollection($access_checked_entities);
+        $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\ResourceType\ResourceType $base_resource_type
+   *   The base resource type from which to resolve an internal include path.
+   * @param string $include_parameter
+   *   The raw include parameter value.
+   * @param string|null $related_field
+   *   A relationship field name if the includes are being resolved on a
+   *   relationship route.
+   *
+   * @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(ResourceType $base_resource_type, $include_parameter, $related_field) {
+    // $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 = static::resolveInternalIncludePaths($base_resource_type, $exploded_paths, $related_field);
+    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.
+   * @param string|null $related_field
+   *   A relationship field name if the includes are being resolved on a
+   *   relationship route.
+   *
+   * @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, $related_field) {
+    $internal_paths = array_map(function ($exploded_path) use ($base_resource_type, $related_field) {
+      if (empty($exploded_path)) {
+        return [];
+      }
+      $resolved_paths = FieldResolver::resolveInternalIncludePath($base_resource_type, $related_field ? array_merge([$related_field], $exploded_path) : $exploded_path);
+      return $related_field
+        ? array_map(function ($resolved_path) {
+          return array_slice($resolved_path, 1);
+        }, $resolved_paths)
+        : $resolved_paths;
+    }, $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 0000000..dd7504e
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/EntityCollection.php
@@ -0,0 +1,189 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\LabelOnlyEntity;
+
+/**
+ * Wrapper to normalize collections with multiple entities.
+ *
+ * @internal
+ */
+class EntityCollection implements \IteratorAggregate, \Countable {
+
+  /**
+   * Entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface[]
+   */
+  protected $entities;
+
+  /**
+   * 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\Core\Entity\EntityInterface|null[]|false[] $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::assertAll(function ($entity) {
+        return $entity === NULL
+        || $entity === FALSE
+        || $entity instanceof EntityInterface
+        || $entity instanceof LabelOnlyEntity
+        || $entity instanceof EntityAccessDeniedHttpException;
+    }, $resources));
+    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->entities = array_values($resources);
+    $this->cardinality = $cardinality;
+  }
+
+  /**
+   * Returns an iterator for entities.
+   *
+   * @return \ArrayIterator
+   *   An \ArrayIterator instance
+   */
+  public function getIterator() {
+    return new \ArrayIterator($this->entities);
+  }
+
+  /**
+   * Returns the number of entities.
+   *
+   * @return int
+   *   The number of parameters
+   */
+  public function count() {
+    return count($this->entities);
+  }
+
+  /**
+   * {@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->entities;
+  }
+
+  /**
+   * 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) {
+      if ($resource instanceof EntityInterface) {
+        $resource_identifier = ResourceIdentifier::fromEntity($resource);
+        $dedupe_key = $resource_identifier->getTypeName() . ':' . $resource_identifier->getId();
+      }
+      else {
+        $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 0000000..954a5ef
--- /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 0000000..74eca6c
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Component\Assertion\Inspector;
+
+/**
+ * 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\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   */
+  protected $data;
+
+  /**
+   * The metadata to normalize.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * The links.
+   *
+   * @var string[]
+   */
+  protected $links;
+
+  /**
+   * The includes to normalize.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\EntityCollection
+   */
+  protected $includes;
+
+  /**
+   * Instantiates a JsonApiDocumentTopLevel object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
+   *   The data to normalize. It can be either a straight up entity or a
+   *   collection of entities.
+   * @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 string[] $links
+   *   The URLs to which the top-level document should link. Keys are strings.
+   *   Values are URLs.
+   * @param array $meta
+   *   (optional) The metadata to normalize.
+   */
+  public function __construct($data, EntityCollection $includes, array $links, array $meta = []) {
+    assert(!$data instanceof ErrorCollection || $includes instanceof NullEntityCollection);
+    assert(Inspector::assertAll(function ($link) {
+      return is_array($link) || isset($link['href']) && is_string($link['href']);
+    }, $links));
+
+    $this->data = $data;
+    $this->includes = $includes;
+    $this->links = $links;
+    $this->meta = $meta;
+  }
+
+  /**
+   * Gets the data.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   *   The data.
+   */
+  public function getData() {
+    return $this->data;
+  }
+
+  /**
+   * Gets the links.
+   *
+   * @return string[]
+   *   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/NullEntityCollection.php b/core/modules/jsonapi/src/JsonApiResource/NullEntityCollection.php
new file mode 100644
index 0000000..750ce32
--- /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 0000000..0a0925a
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifier.php
@@ -0,0 +1,292 @@
+<?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;
+
+/**
+ * Represents a JSON:API resource identifier object.
+ *
+ * @internal
+ */
+class ResourceIdentifier implements ResourceIdentifierInterface {
+
+  const ARITY_KEY = 'arity';
+
+  /**
+   * The JSON:API resource type name.
+   *
+   * @var string
+   */
+  protected $resourceTypeName;
+
+  /**
+   * The resource ID.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The relationship's metadata.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * ResourceIdentifier constructor.
+   *
+   * @param string $resource_type_name
+   *   The JSON:API resource type name.
+   * @param string $id
+   *   The resource ID.
+   * @param array $meta
+   *   Any metadata for the ResourceIdentifier.
+   */
+  public function __construct($resource_type_name, $id, array $meta = []) {
+    assert(!isset($meta[static::ARITY_KEY]) || is_int($meta[static::ARITY_KEY]) && $meta[static::ARITY_KEY] >= 0);
+    $this->resourceTypeName = $resource_type_name;
+    $this->id = $id;
+    $this->meta = $meta;
+  }
+
+  /**
+   * Gets the ResourceIdentifier's JSON:API resource type name.
+   *
+   * @return string
+   *   The JSON:API resource type name.
+   */
+  public function getTypeName() {
+    return $this->resourceTypeName;
+  }
+
+  /**
+   * 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->getTypeName(), $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.
+   *
+   * @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;
+  }
+
+  /**
+   * 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();
+    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'.
+    $meta = array_diff_key($item->getValue(), array_flip([$property_name, $item->getDataDefinition()->getMainPropertyName()]));
+    if (!is_null($arity)) {
+      $meta[static::ARITY_KEY] = $arity;
+    }
+    return new static($resource_type->getTypeName(), $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) {
+      $relationship = static::toResourceIdentifier($item, 0);
+      /* @var self $existing */
+      foreach (array_reverse($relationships) as $index => $existing) {
+        $is_duplicate = static::isDuplicate($existing, $relationship);
+        if ($is_duplicate) {
+          $relationships[] = $relationship->withArity($relationships[$index]->getArity() + 1);
+          continue 2;
+        }
+      }
+      $relationships[] = $relationship;
+    }
+    return $relationships;
+  }
+
+  /**
+   * 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->getTypeName(), $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;
+      }
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php
new file mode 100644
index 0000000..794a814
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierInterface.php
@@ -0,0 +1,33 @@
+<?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();
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php
new file mode 100644
index 0000000..8998992
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiResource/ResourceIdentifierTrait.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+/**
+ * Used to associate an object like an exception to a particular resource.
+ *
+ * @internal
+ */
+trait ResourceIdentifierTrait {
+
+  /**
+   * A ResourceIdentifier object.
+   *
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifier
+   */
+  protected $resourceIdentifier;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getId() {
+    return $this->resourceIdentifier->getId();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTypeName() {
+    return $this->resourceIdentifier->getTypeName();
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiSpec.php b/core/modules/jsonapi/src/JsonApiSpec.php
new file mode 100644
index 0000000..5bad0e1
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonApiSpec.php
@@ -0,0 +1,136 @@
+<?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';
+
+  /**
+   * 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 0000000..c2cf9b3
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonapiServiceProvider.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+use Drupal\Core\DependencyInjection\ServiceProviderInterface;
+use Drupal\jsonapi\DependencyInjection\Compiler\RegisterSerializationClassesCompilerPass;
+use Drupal\jsonapi\DependencyInjection\Compiler\RemoveJsonapiFormatCompilerPass;
+use Symfony\Component\DependencyInjection\Compiler\PassConfig;
+
+/**
+ * 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(), '\Drupal\Core\StackMiddleware\NegotiationMiddleware', 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());
+    $container->addCompilerPass(new RemoveJsonapiFormatCompilerPass(), PassConfig::TYPE_REMOVE);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/LabelOnlyEntity.php b/core/modules/jsonapi/src/LabelOnlyEntity.php
new file mode 100644
index 0000000..0d8c73e
--- /dev/null
+++ b/core/modules/jsonapi/src/LabelOnlyEntity.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierTrait;
+
+/**
+ * Value object decorating an Entity object; only its label is to be normalized.
+ *
+ * @internal
+ */
+class LabelOnlyEntity implements CacheableDependencyInterface, ResourceIdentifierInterface {
+
+  use CacheableDependencyTrait;
+  use ResourceIdentifierTrait;
+
+  /**
+   * Constructs a LabelOnlyEntity value object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to only normalize its label.
+   */
+  public function __construct(EntityInterface $entity) {
+    $this->resourceIdentifier = ResourceIdentifier::fromEntity($entity);
+    $this->entity = $entity;
+    $this->setCacheability($entity);
+  }
+
+  /**
+   * Gets the decorated entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The label for which to only normalize its label.
+   */
+  public function getEntity() {
+    return $this->entity;
+  }
+
+  /**
+   * Determines the entity type's (internal) label field name.
+   */
+  public 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/LinkManager/LinkManager.php b/core/modules/jsonapi/src/LinkManager/LinkManager.php
new file mode 100644
index 0000000..ec00d97
--- /dev/null
+++ b/core/modules/jsonapi/src/LinkManager/LinkManager.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Drupal\jsonapi\LinkManager;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\Core\Url;
+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
+ *
+ * @todo Make this take cacheability into account in https://www.drupal.org/project/jsonapi/issues/2952714.
+ */
+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 string
+   *   The full URL.
+   */
+  public function getRequestLink(Request $request, $query = NULL) {
+    if ($query === NULL) {
+      return $request->getUri();
+    }
+
+    $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
+    return Url::fromUri($uri_without_query_string)->setOption('query', $query)->toString();
+  }
+
+  /**
+   * Get the pager links for a given request object.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   * @param array $link_context
+   *   An associative array with extra data to build the links.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   When the offset and size are invalid.
+   *
+   * @return string[]
+   *   An array of URLs, 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, array $link_context = []) {
+    if (!empty($link_context['total_count']) && !$total = (int) $link_context['total_count']) {
+      return [];
+    }
+    $params = $request->get('_json_api_params');
+    if ($page_param = $params[OffsetPage::KEY_NAME]) {
+      /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
+      $offset = $page_param->getOffset();
+      $size = $page_param->getSize();
+    }
+    else {
+      // Apply the defaults.
+      $offset = OffsetPage::DEFAULT_OFFSET;
+      $size = OffsetPage::SIZE_MAX;
+    }
+    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();
+    $links = [];
+    // Check if this is not the last page.
+    if ($link_context['has_next_page']) {
+      $links['next']['href'] = $this->getRequestLink($request, $this->getPagerQueries('next', $offset, $size, $query));
+
+      if (!empty($total)) {
+        $links['last']['href'] = $this->getRequestLink($request, $this->getPagerQueries('last', $offset, $size, $query, $total));
+      }
+    }
+    // Check if this is not the first page.
+    if ($offset > 0) {
+      $links['first']['href'] = $this->getRequestLink($request, $this->getPagerQueries('first', $offset, $size, $query));
+      $links['prev']['href'] = $this->getRequestLink($request, $this->getPagerQueries('prev', $offset, $size, $query));
+    }
+
+    return $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/ConfigEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
new file mode 100644
index 0000000..f6c528c
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\jsonapi\Normalizer\Value\ConfigFieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Converts the Drupal config entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+class ConfigEntityNormalizer extends EntityNormalizer {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFields($entity, $bundle, ResourceType $resource_type) {
+    $enabled_public_fields = [];
+    $fields = $entity->toArray();
+    // Filter the array based on the field names. Some config entity types don't
+    // have a complete field mapping available and their fields can't be
+    // enabled or disabled. Thus this code should only filter out fields that
+    // are known to exist and are not enabled.
+    $enabled_field_names = array_filter(array_keys($fields), function ($field_name) use ($resource_type) {
+      return !$resource_type->hasField($field_name) || $resource_type->isFieldEnabled($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 = $resource_type->getPublicName($field_name);
+      $enabled_public_fields[$public_field_name] = $field_value;
+    }
+    return $enabled_public_fields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function serializeField($field, array $context, $format) {
+    return new FieldNormalizerValue(
+      // Config entities have no concept of "fields", nor any concept of
+      // "field access". For practical reasons, JSON:API uses the same value
+      // object that it uses for content entities (FieldNormalizerValue), and
+      // that requires an access result. Therefore we can safely hardcode it.
+      AccessResult::allowed(),
+      [new ConfigFieldItemNormalizerValue($field)],
+      1,
+      'attributes'
+    );
+  }
+
+  /**
+   * {@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/ContentEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php
new file mode 100644
index 0000000..db3c2db
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+/**
+ * Converts the Drupal content entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+class ContentEntityNormalizer extends EntityNormalizer {}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php
new file mode 100644
index 0000000..5033d8b
--- /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/EntityConditionGroupNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityConditionGroupNormalizer.php
new file mode 100644
index 0000000..26664e6
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityConditionGroupNormalizer.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The normalizer used for entity conditions.
+ *
+ * @internal
+ */
+class EntityConditionGroupNormalizer implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityConditionGroup::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $type === $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    return new EntityConditionGroup($data['conjunction'], $data['members']);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityConditionNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityConditionNormalizer.php
new file mode 100644
index 0000000..ebba885
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityConditionNormalizer.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Query\EntityCondition;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * The normalizer used for entity conditions.
+ *
+ * @internal
+ */
+class EntityConditionNormalizer implements DenormalizerInterface {
+
+  /**
+   * 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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityCondition::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $type === $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $this->validate($data);
+    $field = $data[static::PATH_KEY];
+    $value = (isset($data[static::VALUE_KEY])) ? $data[static::VALUE_KEY] : NULL;
+    $operator = (isset($data[static::OPERATOR_KEY])) ? $data[static::OPERATOR_KEY] : NULL;
+    return new EntityCondition($field, $value, $operator);
+  }
+
+  /**
+   * Validates the filter has the required fields.
+   */
+  protected function validate($data) {
+    $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($data);
+    $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($data[static::OPERATOR_KEY]);
+    $has_path_key = isset($data[static::PATH_KEY]);
+    $has_value_key = isset($data[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 = $data[static::OPERATOR_KEY];
+      if (!in_array($operator, EntityCondition::$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/Normalizer/EntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php
new file mode 100644
index 0000000..f6b0239
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php
@@ -0,0 +1,287 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValueInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+class EntityNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = ContentEntityInterface::class;
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * 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 EntityNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @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(LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager) {
+    $this->linkManager = $link_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($entity, $format = NULL, array $context = []) {
+    // If the fields to use were specified, only output those field values.
+    $context['resource_type'] = $resource_type = $this->resourceTypeRepository->get(
+      $entity->getEntityTypeId(),
+      $entity->bundle()
+    );
+    $resource_type_name = $resource_type->getTypeName();
+    // 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.
+    $bundle = $resource_type->getBundle();
+    if (!empty($context['sparse_fieldset'][$resource_type_name])) {
+      $field_names = $context['sparse_fieldset'][$resource_type_name];
+    }
+    else {
+      $field_names = $this->getFieldNames($entity, $bundle, $resource_type);
+    }
+    /* @var Value\FieldNormalizerValueInterface[] $normalizer_values */
+    $normalizer_values = [];
+    foreach ($this->getFields($entity, $bundle, $resource_type) 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;
+      }
+      $normalized_field = $this->serializeField($field, $context, $format);
+      assert($normalized_field instanceof FieldNormalizerValueInterface);
+      $normalizer_values[$field_name] = $normalized_field;
+    }
+
+    $link_context = ['link_manager' => $this->linkManager];
+    return new EntityNormalizerValue($normalizer_values, $context, $entity, $link_context);
+  }
+
+  /**
+   * {@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[$resource_type->getPublicName($bundle_key)] = $bundle;
+    }
+
+    return $this->entityTypeManager->getStorage($entity_type_id)
+      ->create($this->prepareInput($data, $resource_type, $format, $context));
+  }
+
+  /**
+   * Gets the field names for the given entity.
+   *
+   * @param mixed $entity
+   *   The entity.
+   * @param string $bundle
+   *   The entity bundle.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type.
+   *
+   * @return string[]
+   *   The field names.
+   */
+  protected function getFieldNames($entity, $bundle, ResourceType $resource_type) {
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    return array_keys($this->getFields($entity, $bundle, $resource_type));
+  }
+
+  /**
+   * Gets the field names for the given entity.
+   *
+   * @param mixed $entity
+   *   The entity.
+   * @param string $bundle
+   *   The bundle id.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type.
+   *
+   * @return array
+   *   The fields.
+   */
+  protected function getFields($entity, $bundle, ResourceType $resource_type) {
+    $output = [];
+    $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(
+      array_keys($fields),
+      [$resource_type, '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()) {
+      $label_field_name = $entity_type->getKey('label');
+      // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands.
+      if ($entity->getEntityTypeId() === 'user') {
+        $label_field_name = 'name';
+      }
+      $fields[$label_field_name]->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));
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    foreach ($input as $field_name => $field_value) {
+      $public_field_name = $resource_type->getPublicName($field_name);
+      $output[$public_field_name] = $field_value;
+    }
+    return $output;
+  }
+
+  /**
+   * Serializes a given field.
+   *
+   * @param mixed $field
+   *   The field to serialize.
+   * @param array $context
+   *   The normalization context.
+   * @param string $format
+   *   The serialization format.
+   *
+   * @return Value\FieldNormalizerValueInterface
+   *   The normalized value.
+   */
+  protected function serializeField($field, array $context, $format) {
+    return $this->serializer->normalize($field, $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.
+   */
+  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);
+    $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);
+
+      // Fail for any disabled field unless it is the uuid key, which is
+      // disabled because it's transmitted as the `id` key of a resource object.
+      // However, for the purpose of denormalization, it exists in this array.
+      // @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+      if (!$resource_type->isFieldEnabled($internal_name) && $uuid_key !== $internal_name) {
+        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/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
new file mode 100644
index 0000000..bbe5dae
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
+
+/**
+ * Normalizer class specific for entity reference field objects.
+ *
+ * @internal
+ */
+class EntityReferenceFieldNormalizer extends FieldNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * Instantiates a EntityReferenceFieldNormalizer object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+   *   The entity repository.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, EntityRepositoryInterface $entity_repository) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->entityRepository = $entity_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = []) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+
+    $field_access = $field->access('view', $context['account'], TRUE);
+    if (!$field_access->isAllowed()) {
+      return new NullFieldNormalizerValue($field_access, 'relationships');
+    }
+
+    $cacheabilty = CacheableMetadata::createFromObject($field_access);
+
+    // Build the relationship object based on the Entity Reference and normalize
+    // that object instead.
+    $main_property = $field->getItemDefinition()->getMainPropertyName();
+    $definition = $field->getFieldDefinition();
+    $cardinality = $definition
+      ->getFieldStorageDefinition()
+      ->getCardinality();
+    $entity_list_metadata = [];
+    $entity_list = [];
+    foreach ($field->filterEmptyItems() as $item) {
+      // 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 ($item->get('entity')->getValue() === NULL) {
+        if ($field->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type') === 'taxonomy_term' && $item->get('target_id')->getCastedValue() === 0) {
+          $entity_list[] = NULL;
+          $entity_list_metadata[] = [
+            '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.",
+                ],
+              ],
+            ],
+          ];
+        }
+        else {
+          $entity_list[] = FALSE;
+          $entity_list_metadata[] = [
+            '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.",
+                ],
+              ],
+            ],
+          ];
+        }
+        continue;
+      }
+
+      // Prepare a list of additional properties stored by the field.
+      $metadata = [];
+      /** @var \Drupal\Core\TypedData\TypedDataInterface[] $properties */
+      $properties = TypedDataInternalPropertiesHelper::getNonInternalProperties($item);
+
+      // 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.
+      // @see \Drupal\jsonapi\Normalizer\FieldItemNormalizer::normalize()
+      $context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY] = new CacheableMetadata();
+      foreach ($properties as $property_key => $property) {
+        if ($property_key !== $main_property) {
+          $metadata[$property_key] = $this->serializer->normalize($property, $format, $context);
+        }
+      }
+      $cacheabilty = $cacheabilty->merge($context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
+      unset($context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
+      $entity_list_metadata[] = $metadata;
+
+      // Get the referenced entity.
+      $entity = $item->get('entity')->getValue();
+
+      if ($this->isInternalResourceType($entity)) {
+        continue;
+      }
+
+      // And get the translation in the requested language.
+      $entity_list[] = $this->entityRepository->getTranslationFromContext($entity);
+    }
+    $entity_collection = new EntityCollection($entity_list, $cardinality);
+    $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $entity_collection, $field->getEntity(), $cacheabilty, $cardinality, $main_property, $entity_list_metadata);
+    return $this->serializer->normalize($relationship, $format, $context);
+  }
+
+  /**
+   * Determines if the given entity is of an internal resource type.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to check the internal status.
+   *
+   * @return bool
+   *   TRUE if the entity's resource type is internal, FALSE otherwise.
+   */
+  protected function isInternalResourceType(EntityInterface $entity) {
+    return ($resource_type = $this->resourceTypeRepository->get(
+      $entity->getEntityTypeId(),
+      $entity->bundle()
+    )) && $resource_type->isInternal();
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
new file mode 100644
index 0000000..6a3ebf7
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -0,0 +1,101 @@
+<?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\FieldItemNormalizerValue;
+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;
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@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 property, 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'];
+    }
+    $value = new FieldItemNormalizerValue($values, $context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
+    unset($context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
+    return $value;
+  }
+
+  /**
+   * {@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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
new file mode 100644
index 0000000..b40a770
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
+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;
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = []) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+
+    $access = $field->access('view', $context['account'], TRUE);
+    $property_type = static::isRelationship($field) ? 'relationships' : 'attributes';
+
+    if ($access->isAllowed()) {
+      $normalized_field_items = $this->normalizeFieldItems($field, $format, $context);
+      assert(Inspector::assertAll(function ($v) {
+        return $v instanceof FieldItemNormalizerValue;
+      }, $normalized_field_items));
+
+      $cardinality = $field->getFieldDefinition()
+        ->getFieldStorageDefinition()
+        ->getCardinality();
+      return new FieldNormalizerValue($access, $normalized_field_items, $cardinality, $property_type);
+    }
+    else {
+      return new NullFieldNormalizerValue($access, $property_type);
+    }
+  }
+
+  /**
+   * Checks if the passed field is a relationship field.
+   *
+   * @param mixed $field
+   *   The field.
+   *
+   * @return bool
+   *   TRUE if it's a JSON:API relationship.
+   */
+  protected static function isRelationship($field) {
+    return $field instanceof EntityReferenceFieldItemList || $field instanceof Relationship;
+  }
+
+  /**
+   * {@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/FilterNormalizer.php b/core/modules/jsonapi/src/Normalizer/FilterNormalizer.php
new file mode 100644
index 0000000..94c5fd2
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FilterNormalizer.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\Filter;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The normalizer used for JSON:API filters.
+ *
+ * @internal
+ */
+class FilterNormalizer implements DenormalizerInterface {
+
+  /**
+   * 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 interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = Filter::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The entity condition denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $conditionDenormalizer;
+
+  /**
+   * The entity condition group denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $groupDenormalizer;
+
+  /**
+   * The field resolver service.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $fieldResolver;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(FieldResolver $field_resolver, DenormalizerInterface $condition_denormalizer, DenormalizerInterface $group_denormalizer) {
+    $this->fieldResolver = $field_resolver;
+    $this->conditionDenormalizer = $condition_denormalizer;
+    $this->groupDenormalizer = $group_denormalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $type === $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data, $context);
+    $denormalized = $this->denormalizeItems($expanded);
+    return new Filter($denormalized);
+  }
+
+  /**
+   * Expands any filter parameters using shorthand notation.
+   *
+   * @param array $original
+   *   The unexpanded filter data.
+   * @param array $context
+   *   The denormalization context.
+   *
+   * @return array
+   *   The expanded filter data.
+   */
+  protected function expand(array $original, array $context) {
+    $expanded = [];
+    foreach ($original as $key => $item) {
+      // Allow extreme shorthand filters, f.e. `?filter[promote]=1`.
+      if (!is_array($item)) {
+        $item = [
+          EntityConditionNormalizer::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] = $this->expandItem($key, $item, $context);
+    }
+
+    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.
+   * @param array $context
+   *   The denormalization context.
+   *
+   * @return array
+   *   The expanded filter item.
+   */
+  protected function expandItem($filter_index, array $filter_item, array $context) {
+    if (isset($filter_item[EntityConditionNormalizer::VALUE_KEY])) {
+      if (!isset($filter_item[EntityConditionNormalizer::PATH_KEY])) {
+        $filter_item[EntityConditionNormalizer::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][EntityConditionNormalizer::OPERATOR_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY] = '=';
+    }
+
+    if (isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY] = $this->fieldResolver->resolveInternalEntityQueryPath(
+        $context['entity_type_id'],
+        $context['bundle'],
+        $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_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 function denormalizeItems(array $items) {
+    $root = [
+      'id' => static::ROOT_ID,
+      static::GROUP_KEY => ['conjunction' => 'AND'],
+    ];
+    return $this->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 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, $this->buildTree($item, $items));
+        }
+        elseif (isset($item[static::CONDITION_KEY])) {
+          $condition = $this->conditionDenormalizer->denormalize(
+            $item[static::CONDITION_KEY],
+            EntityCondition::class
+          );
+          array_push($members, $condition);
+        }
+      }
+    }
+
+    $root[static::GROUP_KEY]['members'] = $members;
+
+    // Denormalize the root into a condition group.
+    return $this->groupDenormalizer->denormalize(
+      $root[static::GROUP_KEY],
+      EntityConditionGroup::class
+    );
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
new file mode 100644
index 0000000..44b2b16
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+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 {
+
+  /**
+   * 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 = []) {
+    $errors = $this->buildErrorObjects($object);
+
+    $errors = array_map(function ($error) {
+      // @todo Either this should not use FieldItemNormalizerValue, or FieldItemNormalizerValue needs to be renamed to not be semantically coupled to "fields".
+      return new FieldItemNormalizerValue([$error], new CacheableMetadata());
+    }, $errors);
+
+    // @todo The access result, cardinality and property type make no sense for HTTP exceptions, but it's because HttpExceptionNormalizerValue inappropriately subclasses FieldNormalizerValue
+    return new HttpExceptionNormalizerValue(
+      AccessResult::allowed(),
+      $errors,
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
+      'attributes'
+    );
+  }
+
+  /**
+   * 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 0000000..622e434
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -0,0 +1,251 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Uuid\Uuid;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+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 link manager to get the links.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * 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\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager to get the links.
+   * @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(LinkManager $link_manager, EntityTypeManagerInterface $entity_type_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->linkManager = $link_manager;
+    $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.
+        $related_entities = array_values($entity_storage->loadByProperties(['uuid' => $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 = []) {
+    $serializer = $this->serializer;
+
+    $data = $object->getData();
+
+    if ($data instanceof ErrorCollection) {
+      $normalizer_values = array_map(function (HttpExceptionInterface $exception) use ($format, $context, $serializer) {
+        return $serializer->normalize($exception, $format, $context);
+      }, (array) $data->getIterator());
+      return new JsonApiDocumentTopLevelNormalizerValue(JsonApiDocumentTopLevelNormalizerValue::ERROR_DOCUMENT, $normalizer_values, [], FALSE, $object->getMeta());
+    }
+
+    $includes = $omissions = [];
+    foreach ($object->getIncludes() as $include) {
+      $include instanceof EntityAccessDeniedHttpException
+        ? $omissions[] = $serializer->normalize($include, $format, $context)
+        : $includes[] = $serializer->normalize($include, $format, $context);
+    }
+
+    if ($data instanceof EntityReferenceFieldItemListInterface) {
+      $normalizer_values = [
+        $this->serializer->normalize($data, $format, $context),
+      ];
+
+      if (!empty($omissions)) {
+        $normalizer_values = array_merge($normalizer_values, $omissions);
+      }
+
+      // RelationshipNormalizerValues already handle single vs multiple
+      // multiple cardinality fields.
+      $cardinality = 1;
+      return new JsonApiDocumentTopLevelNormalizerValue(JsonApiDocumentTopLevelNormalizerValue::RESOURCE_OBJECT_DOCUMENT, $normalizer_values, [], $cardinality, $includes, $object->getMeta());
+    }
+    $is_collection = $data instanceof EntityCollection;
+    // To improve the logical workflow deal with an array at all times.
+    $entities = $is_collection ? $data->toArray() : [$data];
+    $normalizer_values = array_map(function ($entity) use ($format, $context, $serializer) {
+      return $serializer->normalize($entity, $format, $context);
+    }, $entities);
+
+    if (!empty($omissions)) {
+      $normalizer_values = array_merge($normalizer_values, $omissions);
+    }
+
+    $cardinality = $is_collection ? $data->getCardinality() : 1;
+    return new JsonApiDocumentTopLevelNormalizerValue(JsonApiDocumentTopLevelNormalizerValue::RESOURCE_OBJECT_DOCUMENT, $normalizer_values, $object->getLinks(), $cardinality, $includes, $object->getMeta());
+  }
+
+  /**
+   * Performs mimimal 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)));
+      }
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/LabelOnlyEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/LabelOnlyEntityNormalizer.php
new file mode 100644
index 0000000..9b43e7a
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/LabelOnlyEntityNormalizer.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\LabelOnlyEntity;
+use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+
+/**
+ * Pretends that the entity only has a single field: the label field.
+ *
+ * @see \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
+ *
+ * @internal
+ */
+class LabelOnlyEntityNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = LabelOnlyEntity::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * Constructs an LabelOnlyEntityNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   */
+  public function __construct(LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->linkManager = $link_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($label_only_entity, $format = NULL, array $context = []) {
+    assert($label_only_entity instanceof LabelOnlyEntity);
+    $entity = $label_only_entity->getEntity();
+
+    $context['resource_type'] = $this->resourceTypeRepository->get(
+      $entity->getEntityTypeId(),
+      $entity->bundle()
+    );
+
+    // Determine the (internal) label field name.
+    $label_field_name = $label_only_entity->getLabelFieldName();
+
+    // Determine the public alias for the label field name.
+    assert($context['resource_type'] instanceof ResourceType);
+    $resource_type = $context['resource_type'];
+    $public_field_label_name = $resource_type->getPublicName($label_field_name);
+
+    // Perform the default entity normalization, extract all values from the
+    // resulting EntityNormalizerValue object.
+    // @see \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
+    $full_normalized_entity = $this->serializer->normalize($entity, $format, $context);
+    assert($full_normalized_entity instanceof EntityNormalizerValue);
+    $all_values = $full_normalized_entity->getValues();
+
+    // Reconstruct an EntityNormalizerValue object, this time with only the
+    // label field.
+    $label_only_values = [$public_field_label_name => $all_values[$public_field_label_name]];
+    $link_context = ['link_manager' => $this->linkManager];
+    return new EntityNormalizerValue($label_only_values, $context, $entity, $link_context);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/NormalizerBase.php b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
new file mode 100644
index 0000000..0ea12c8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
@@ -0,0 +1,46 @@
+<?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 {
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL) {
+    return in_array($format, $this->formats, TRUE) && parent::supportsNormalization($data, $format);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    if (in_array($format, $this->formats, TRUE) && (class_exists($this->supportedInterfaceOrClass) || interface_exists($this->supportedInterfaceOrClass))) {
+      $target = new \ReflectionClass($type);
+      $supported = new \ReflectionClass($this->supportedInterfaceOrClass);
+      if ($supported->isInterface()) {
+        return $target->implementsInterface($this->supportedInterfaceOrClass);
+      }
+      else {
+        return ($target->getName() == $this->supportedInterfaceOrClass || $target->isSubclassOf($this->supportedInterfaceOrClass));
+      }
+    }
+
+    return FALSE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/OffsetPageNormalizer.php b/core/modules/jsonapi/src/Normalizer/OffsetPageNormalizer.php
new file mode 100644
index 0000000..6ef9e73
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/OffsetPageNormalizer.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Query\OffsetPage;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * The normalizer used for JSON:API pagination.
+ *
+ * @internal
+ */
+class OffsetPageNormalizer implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = OffsetPage::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data);
+    return new OffsetPage($expanded[OffsetPage::OFFSET_KEY], $expanded[OffsetPage::SIZE_KEY]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand($data) {
+    if (!is_array($data)) {
+      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']);
+      throw new CacheableBadRequestHttpException($cacheability, 'The page parameter needs to be an array.');
+    }
+
+    $expanded = $data + [
+      OffsetPage::OFFSET_KEY => OffsetPage::DEFAULT_OFFSET,
+      OffsetPage::SIZE_KEY => OffsetPage::SIZE_MAX,
+    ];
+
+    if ($expanded[OffsetPage::SIZE_KEY] > OffsetPage::SIZE_MAX) {
+      $expanded[OffsetPage::SIZE_KEY] = OffsetPage::SIZE_MAX;
+    }
+
+    return $expanded;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Relationship.php b/core/modules/jsonapi/src/Normalizer/Relationship.php
new file mode 100644
index 0000000..2711e1c
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Relationship.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessibleInterface;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+
+/**
+ * Represents a relationship between resources.
+ *
+ * Use this class to create a relationship in your normalizer without having an
+ * entity reference field: allows for "virtual" relationships that are not
+ * backed by a stored entity reference.
+ *
+ * @internal
+ */
+class Relationship implements AccessibleInterface, CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+
+  /**
+   * Cardinality.
+   *
+   * @var int
+   */
+  protected $cardinality;
+
+  /**
+   * The entity that holds the relationship.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $hostEntity;
+
+  /**
+   * The field name.
+   *
+   * @var string
+   */
+  protected $propertyName;
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The relationship items.
+   *
+   * @var \Drupal\jsonapi\Normalizer\RelationshipItem[]
+   */
+  protected $items;
+
+  /**
+   * Relationship constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param string $field_name
+   *   The name of the relationship.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entities
+   *   A collection of entities.
+   * @param \Drupal\Core\Entity\EntityInterface $host_entity
+   *   The host entity.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cacheability of this relationship and its metadata.
+   * @param int $cardinality
+   *   The relationship cardinality.
+   * @param string $target_key
+   *   The property name of the relationship id.
+   * @param array $entity_list_metadata
+   *   An array of additional properties stored by the field and that will be
+   *   added to the meta in the relationship.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, $field_name, EntityCollection $entities, EntityInterface $host_entity, CacheableDependencyInterface $cacheability, $cardinality = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, $target_key = 'target_id', array $entity_list_metadata = []) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->propertyName = $field_name;
+    $this->cardinality = $cardinality;
+    $this->hostEntity = $host_entity;
+
+    $this->setCacheability($cacheability);
+
+    $this->items = [];
+    foreach ($entities as $key => $entity) {
+      $this->items[] = new RelationshipItem(
+        $resource_type_repository,
+        $entity,
+        $this,
+        $target_key,
+        $entity_list_metadata[$key]
+      );
+    }
+  }
+
+  /**
+   * Gets the cardinality.
+   *
+   * @return mixed
+   *   The cardinality of this relationship field.
+   */
+  public function getCardinality() {
+    return $this->cardinality;
+  }
+
+  /**
+   * Gets the host entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity which contains this relationship.
+   */
+  public function getHostEntity() {
+    return $this->hostEntity;
+  }
+
+  /**
+   * Sets the host entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $hostEntity
+   *   The host entity.
+   */
+  public function setHostEntity(EntityInterface $hostEntity) {
+    $this->hostEntity = $hostEntity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {
+    // Hard coded to TRUE. Revisit this if we need more control over this.
+    return TRUE;
+  }
+
+  /**
+   * Gets the field name.
+   *
+   * @return string
+   *   The name of the relationship property.
+   */
+  public function getPropertyName() {
+    return $this->propertyName;
+  }
+
+  /**
+   * Gets the items.
+   *
+   * @return \Drupal\jsonapi\Normalizer\RelationshipItem[]
+   *   The relationship items.
+   */
+  public function getItems() {
+    return $this->items;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipItem.php b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php
new file mode 100644
index 0000000..eb770ff
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+
+/**
+ * Value object representing a JSON:API relationship item.
+ *
+ * @internal
+ */
+class RelationshipItem {
+
+  /**
+   * The target key name.
+   *
+   * @var string
+   */
+  protected $targetKey = 'target_id';
+
+  /**
+   * The target entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface|null
+   */
+  protected $targetEntity;
+
+  /**
+   * The target JSON:API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $targetResourceType;
+
+  /**
+   * The parent relationship.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Relationship
+   */
+  protected $parent;
+
+  /**
+   * The list of metadata associated with this relationship item value.
+   *
+   * @var array
+   */
+  protected $metadata;
+
+  /**
+   * Relationship item constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param \Drupal\Core\Entity\EntityInterface|null|false $target_entity
+   *   The entity this relationship points to, if any. NULL if virtual resource.
+   *   FALSE if missing resource (dangling entity reference).
+   * @param \Drupal\jsonapi\Normalizer\Relationship $parent
+   *   The parent of this item.
+   * @param string $target_key
+   *   The key name of the target relationship.
+   * @param array $metadata
+   *   The list of metadata associated with this relationship item value.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, $target_entity, Relationship $parent, $target_key = 'target_id', array $metadata = []) {
+    assert($target_entity === NULL || $target_entity === FALSE || $target_entity instanceof EntityInterface);
+    if ($target_entity === NULL || $target_entity === FALSE) {
+      $host_entity = $parent->getHostEntity();
+      $relatable_resource_types = $resource_type_repository->get(
+        $host_entity->getEntityTypeId(),
+        $host_entity->bundle()
+      )->getRelatableResourceTypes()[$parent->getPropertyName()];
+
+      if ($target_entity === NULL) {
+        if (count($relatable_resource_types) !== 1) {
+          throw new \RuntimeException('Relationships to virtual resources are possible only if a single resource type is relatable.');
+        }
+        $this->targetResourceType = reset($relatable_resource_types);
+      }
+      else {
+        assert($target_entity === FALSE);
+        // 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.
+        $this->targetResourceType = count($relatable_resource_types) > 1
+          ? new ResourceType('?', '?', '')
+          : reset($relatable_resource_types);
+      }
+    }
+    else {
+      $this->targetResourceType = $resource_type_repository->get(
+        $target_entity->getEntityTypeId(),
+        $target_entity->bundle()
+      );
+    }
+    $this->targetKey = $target_key;
+    $this->targetEntity = $target_entity;
+    $this->parent = $parent;
+    $this->metadata = $metadata;
+  }
+
+  /**
+   * Gets the target entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface|null
+   *   The target entity of this relationship item.
+   */
+  public function getTargetEntity() {
+    return $this->targetEntity;
+  }
+
+  /**
+   * Gets the targetResourceConfig.
+   *
+   * @return mixed
+   *   The target of this relationship item.
+   */
+  public function getTargetResourceType() {
+    return $this->targetResourceType;
+  }
+
+  /**
+   * Gets the relationship value.
+   *
+   * Defaults to the entity ID.
+   *
+   * @return string
+   *   The value of this relationship item.
+   */
+  public function getValue() {
+    $target_uuid = $this->targetEntity === NULL
+      ? 'virtual'
+      : ($this->targetEntity === FALSE
+        ? 'missing'
+        : $this->getTargetEntity()->uuid());
+
+    return [
+      'target_uuid' => $target_uuid,
+      'meta' => $this->metadata,
+    ];
+  }
+
+  /**
+   * Gets the relationship object that contains this relationship item.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Relationship
+   *   The parent relationship of this item.
+   */
+  public function getParent() {
+    return $this->parent;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php
new file mode 100644
index 0000000..96e5538
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+
+/**
+ * Converts the Drupal entity reference item object to a JSON:API structure.
+ *
+ * @internal
+ */
+class RelationshipItemNormalizer extends FieldItemNormalizer {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = RelationshipItem::class;
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * Instantiates a RelationshipItemNormalizer object.
+   *
+   * @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 normalize($relationship_item, $format = NULL, array $context = []) {
+    /* @var $relationship_item \Drupal\jsonapi\Normalizer\RelationshipItem */
+    // TODO: We are always loading the referenced entity. Even if it is not
+    // going to be included. That may be a performance issue. We do it because
+    // we need to know the entity type and bundle to load the JSON:API resource
+    // type for the relationship item. We need a better way of finding about
+    // this.
+    $values = $relationship_item->getValue();
+    if (isset($context['langcode'])) {
+      $values['lang'] = $context['langcode'];
+    }
+
+    return new RelationshipItemNormalizerValue(
+      $values,
+      new CacheableMetadata(),
+      $relationship_item->getTargetResourceType()
+    );
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
new file mode 100644
index 0000000..feb737a
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+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 RelationshipNormalizer extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = Relationship::class;
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * RelationshipNormalizer constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON:API resource type repository.
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
+   *   The entity repository.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, LinkManager $link_manager, EntityFieldManagerInterface $field_manager, EntityRepositoryInterface $entity_repository) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->linkManager = $link_manager;
+    $this->fieldManager = $field_manager;
+    $this->entityRepository = $entity_repository;
+  }
+
+  /**
+   * {@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;
+  }
+
+  /**
+   * Helper function to normalize field items.
+   *
+   * @param \Drupal\jsonapi\Normalizer\Relationship|object $relationship
+   *   The field object.
+   * @param string $format
+   *   The format.
+   * @param array $context
+   *   The context array.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue
+   *   The array of normalized field items.
+   */
+  public function normalize($relationship, $format = NULL, array $context = []) {
+    /* @var \Drupal\jsonapi\Normalizer\Relationship $relationship */
+    $normalizer_items = [];
+    foreach ($relationship->getItems() as $relationship_item) {
+      // If the relationship points to a disabled resource type, do not add the
+      // normalized relationship item.
+      if (!$relationship_item->getTargetResourceType()) {
+        continue;
+      }
+      $normalizer_items[] = $this->serializer->normalize($relationship_item, $format, $context);
+    }
+    $cardinality = $relationship->getCardinality();
+    assert($context['resource_type'] instanceof ResourceType);
+    $resource_type = $context['resource_type'];
+    $link_context = [
+      'host_entity_id' => $relationship->getHostEntity()->uuid(),
+      'field_name' => $resource_type->getPublicName($relationship->getPropertyName()),
+      'link_manager' => $this->linkManager,
+      'resource_type' => $resource_type,
+    ];
+    // If this is called, access to the Relationship field is allowed. The
+    // cacheability of the access result is carried by the Relationship value
+    // object. Therefore, we can safely construct an access result object here.
+    // Access to the targeted related resources will be checked separately.
+    // @see \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::normalize()
+    // @see \Drupal\jsonapi\Normalizer\RelationshipItemNormalizer::normalize()
+    $relationship_access = AccessResult::allowed()->addCacheableDependency($relationship);
+    return new RelationshipNormalizerValue($relationship_access, $normalizer_items, $cardinality, $link_context);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/SortNormalizer.php b/core/modules/jsonapi/src/Normalizer/SortNormalizer.php
new file mode 100644
index 0000000..0717fe2
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/SortNormalizer.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Context\FieldResolver;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+
+/**
+ * The normalizer used for JSON:API sorts.
+ *
+ * @internal
+ */
+class SortNormalizer implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = Sort::class;
+
+  /**
+   * The field resolver service.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $fieldResolver;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(FieldResolver $field_resolver) {
+    $this->fieldResolver = $field_resolver;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data);
+    $expanded = array_map(function ($item) use ($context) {
+      $item[Sort::PATH_KEY] = $this->fieldResolver->resolveInternalEntityQueryPath(
+        $context['entity_type_id'],
+        $context['bundle'],
+        $item[Sort::PATH_KEY]
+      );
+      return $item;
+    }, $expanded);
+    return new Sort($expanded);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand($sort) {
+    if (empty($sort)) {
+      $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($sort)) {
+      $sort = $this->expandFieldString($sort);
+    }
+
+    // Expand any defaults into the sort array.
+    $expanded = [];
+    foreach ($sort as $sort_index => $sort_item) {
+      $expanded[$sort_index] = $this->expandItem($sort_index, $sort_item);
+    }
+
+    return $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 function expandFieldString($fields) {
+    return array_map(function ($field) {
+      $sort = [];
+
+      if ($field[0] == '-') {
+        $sort[Sort::DIRECTION_KEY] = 'DESC';
+        $sort[Sort::PATH_KEY] = substr($field, 1);
+      }
+      else {
+        $sort[Sort::DIRECTION_KEY] = 'ASC';
+        $sort[Sort::PATH_KEY] = $field;
+      }
+
+      return $sort;
+    }, explode(',', $fields));
+  }
+
+  /**
+   * Expands a sort item in case a shortcut was used.
+   *
+   * @param string $sort_index
+   *   Unique identifier for the sort parameter being expanded.
+   * @param array $sort_item
+   *   The raw sort item.
+   *
+   * @return array
+   *   The expanded sort item.
+   */
+  protected function expandItem($sort_index, array $sort_item) {
+    $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:sort']);
+    $defaults = [
+      Sort::DIRECTION_KEY => 'ASC',
+      Sort::LANGUAGE_KEY => NULL,
+    ];
+
+    if (!isset($sort_item[Sort::PATH_KEY])) {
+      throw new CacheableBadRequestHttpException($cacheability, 'You need to provide a field name for the sort parameter.');
+    }
+
+    $expected_keys = [
+      Sort::PATH_KEY,
+      Sort::DIRECTION_KEY,
+      Sort::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/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php
new file mode 100644
index 0000000..0ca126b
--- /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 0000000..eb95ddf
--- /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/ConfigFieldItemNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/ConfigFieldItemNormalizerValue.php
new file mode 100644
index 0000000..f6698f3
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/ConfigFieldItemNormalizerValue.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * Helps normalize config entity "fields" in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class ConfigFieldItemNormalizerValue extends FieldItemNormalizerValue {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var mixed
+   */
+  protected $raw;
+
+  /**
+   * Instantiate a ConfigFieldItemNormalizerValue object.
+   *
+   * @param mixed $values
+   *   The normalized result.
+   */
+  public function __construct($values) {
+    $this->raw = $values;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    return $this->rasterizeValueRecursive($this->raw);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php
new file mode 100644
index 0000000..83dc017
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Helps normalize entities in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class EntityNormalizerValue implements ValueExtractorInterface, CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+  use CacheableDependenciesMergerTrait;
+
+  /**
+   * The values.
+   *
+   * @var array
+   */
+  protected $values;
+
+  /**
+   * The resource path.
+   *
+   * @var array
+   */
+  protected $context;
+
+  /**
+   * The resource entity.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * Instantiate a EntityNormalizerValue object.
+   *
+   * @param FieldNormalizerValueInterface[] $values
+   *   The normalized result.
+   * @param array $context
+   *   The context for the normalizer.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param array $link_context
+   *   All the objects and variables needed to generate the links for this
+   *   relationship.
+   */
+  public function __construct(array $values, array $context, EntityInterface $entity, array $link_context) {
+    $this->setCacheability(static::mergeCacheableDependencies(array_merge([$entity], $values)));
+
+    $this->values = array_filter($values, function ($value) {
+      return !($value instanceof NullFieldNormalizerValue);
+    });
+    $this->context = $context;
+    $this->entity = $entity;
+    $this->linkManager = $link_context['link_manager'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    // Create the array of normalized fields, starting with the URI.
+    $rasterized = [
+      'type' => $this->context['resource_type']->getTypeName(),
+      'id' => $this->entity->uuid(),
+      'attributes' => [],
+      'relationships' => [],
+    ];
+    $rasterized['links']['self']['href'] = $this->linkManager->getEntityLink(
+      $rasterized['id'],
+      $this->context['resource_type'],
+      [],
+      'individual'
+    );
+
+    foreach ($this->getValues() as $field_name => $normalizer_value) {
+      $rasterized[$normalizer_value->getPropertyType()][$field_name] = $normalizer_value->rasterizeValue();
+    }
+    return array_filter($rasterized);
+  }
+
+  /**
+   * Gets the values.
+   *
+   * @return mixed
+   *   The values.
+   */
+  public function getValues() {
+    return $this->values;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php
new file mode 100644
index 0000000..1502a09
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+
+/**
+ * Helps normalize field items in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class FieldItemNormalizerValue implements CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+
+  /**
+   * Raw values.
+   *
+   * @var array
+   */
+  protected $raw;
+
+  /**
+   * Instantiate a FieldItemNormalizerValue object.
+   *
+   * @param array $values
+   *   The normalized result.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $values_cacheability
+   *   The cacheability of the normalized result. This cacheability is not part
+   *   of $values because field items are normalized by Drupal core's
+   *   serialization system, which was never designed with cacheability in mind.
+   *   FieldItemNormalizer::normalize() must catch the out-of-band bubbled
+   *   cacheability and then passes it to this value object.
+   *
+   * @see \Drupal\jsonapi\Normalizer\FieldItemNormalizer::normalize()
+   */
+  public function __construct(array $values, CacheableDependencyInterface $values_cacheability) {
+    $this->raw = $values;
+    $this->setCacheability($values_cacheability);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    // If there is only one property, then output it directly.
+    $value = count($this->raw) == 1 ? reset($this->raw) : $this->raw;
+
+    return $this->rasterizeValueRecursive($value);
+  }
+
+  /**
+   * 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 function rasterizeValueRecursive($value) {
+    if (!$value || is_scalar($value)) {
+      return $value;
+    }
+    if (is_array($value)) {
+      $output = [];
+      foreach ($value as $key => $item) {
+        $output[$key] = $this->rasterizeValueRecursive($item);
+      }
+
+      return $output;
+    }
+    if ($value instanceof ValueExtractorInterface) {
+      return $value->rasterizeValue();
+    }
+    // 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/Value/FieldNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php
new file mode 100644
index 0000000..ffa6c51
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+
+/**
+ * Helps normalize fields in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class FieldNormalizerValue implements FieldNormalizerValueInterface {
+
+  use CacheableDependencyTrait;
+  use CacheableDependenciesMergerTrait;
+
+  /**
+   * The values.
+   *
+   * @var array
+   */
+  protected $values;
+
+  /**
+   * The field cardinality.
+   *
+   * @var int
+   */
+  protected $cardinality;
+
+  /**
+   * The property type. Either: 'attributes' or `relationships'.
+   *
+   * @var string
+   */
+  protected $propertyType;
+
+  /**
+   * Instantiate a FieldNormalizerValue object.
+   *
+   * @param \Drupal\Core\Access\AccessResultInterface $field_access_result
+   *   The field access result.
+   * @param \Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue[] $values
+   *   The normalized result.
+   * @param int $cardinality
+   *   The cardinality of the field list.
+   * @param string $property_type
+   *   The property type of the field: 'attributes' or 'relationships'.
+   */
+  public function __construct(AccessResultInterface $field_access_result, array $values, $cardinality, $property_type) {
+    assert($property_type === 'attributes' || $property_type === 'relationships');
+    $this->setCacheability(static::mergeCacheableDependencies(array_merge([$field_access_result], $values)));
+
+    $this->values = $values;
+    $this->cardinality = $cardinality;
+    $this->propertyType = $property_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (empty($this->values)) {
+      return NULL;
+    }
+
+    if ($this->cardinality == 1) {
+      assert(count($this->values) === 1);
+      return $this->values[0] instanceof FieldItemNormalizerValue
+        ? $this->values[0]->rasterizeValue() : NULL;
+    }
+
+    return array_map(function ($value) {
+      return $value instanceof FieldItemNormalizerValue ? $value->rasterizeValue() : NULL;
+    }, $this->values);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPropertyType() {
+    return $this->propertyType;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php
new file mode 100644
index 0000000..d0a52b2
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+
+/**
+ * Interface to help normalize fields in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+interface FieldNormalizerValueInterface extends ValueExtractorInterface, CacheableDependencyInterface {
+
+  /**
+   * Gets the propertyType.
+   *
+   * @return mixed
+   *   The propertyType.
+   */
+  public function getPropertyType();
+
+}
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 0000000..3f8ccfc
--- /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 FieldNormalizerValue {}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
new file mode 100644
index 0000000..d633330
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+use Drupal\jsonapi\JsonApiSpec;
+
+/**
+ * Helps normalize the top level document in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface, RefinableCacheableDependencyInterface {
+
+  use RefinableCacheableDependencyTrait;
+
+  const RESOURCE_OBJECT_DOCUMENT = 'resource_object_document';
+
+  const ERROR_DOCUMENT = 'error_document';
+
+  /**
+   * The values.
+   *
+   * @var array
+   */
+  protected $values;
+
+  /**
+   * The type of document that this instance is..
+   *
+   * The spec says the top-level `data` and `errors` members MUST NOT coexist,
+   * therefore, a document can either be a "resource object document" or an
+   * "error document".
+   *
+   * @var string
+   *
+   * @see http://jsonapi.org/format/#document-top-level
+   */
+  protected $documentType;
+
+  /**
+   * The includes.
+   *
+   * @var array
+   */
+  protected $includes;
+
+  /**
+   * The links. Keys are link relation types.
+   *
+   * @var string[]
+   */
+  protected $links;
+
+  /**
+   * The cardinality of the document's primary data.
+   *
+   * @var int
+   */
+  protected $cardinality;
+
+  /**
+   * The metadata.
+   *
+   * @var array
+   */
+  protected $meta;
+
+  /**
+   * Instantiates a JsonApiDocumentTopLevelNormalizerValue object.
+   *
+   * @param string $document_type
+   *   The document's type. Use either the self::RESOURCE_OBJECT_DOCUMENT or
+   *   self::ERROR_DOCUMENT class constant.
+   * @param \Drupal\Core\Entity\EntityInterface[] $values
+   *   The data to normalize. It can be either a straight up entity or a
+   *   collection of entities.
+   * @param string[] $links
+   *   The URLs to which to link.
+   * @param int|bool $cardinality
+   *   The cardinality of the document's primary data. -1 for unlimited
+   *   cardinality. For example, an individual resource would have a cardinality
+   *   of 1. A related resource would have a cardinality of -1 for a to-many
+   *   relationship, but a cardinality of 1 for a to-one relationship.
+   * @param \Drupal\jsonapi\JsonApiResource\EntityCollection|false $includes
+   *   An entity collection of resource to be included or FALSE if the document
+   *   does not have included resources.
+   * @param array $meta
+   *   (optional) The metadata to normalize.
+   */
+  public function __construct($document_type, array $values, array $links, $cardinality = FALSE, $includes = FALSE, array $meta = []) {
+    assert(in_array($document_type, [static::RESOURCE_OBJECT_DOCUMENT, static::ERROR_DOCUMENT]));
+    assert(is_int($cardinality) || $document_type === static::ERROR_DOCUMENT);
+    $this->documentType = $document_type;
+    $this->values = $values;
+
+    array_walk($values, [$this, 'addCacheableDependency']);
+
+    if (!$this->isErrorDocument()) {
+      // @todo Make this unconditional in https://www.drupal.org/project/jsonapi/issues/2965056.
+      if (!\Drupal::requestStack()->getCurrentRequest()->get('_on_relationship')) {
+        // Make sure that different sparse fieldsets are cached differently.
+        $this->addCacheContexts(array_map(function ($query_parameter_name) {
+          return sprintf('url.query_args:%s', $query_parameter_name);
+        }, ['fields', 'include']));
+      }
+      // Every JSON:API document contains absolute URLs.
+      $this->addCacheContexts(['url.site']);
+
+      $this->cardinality = $cardinality;
+
+      assert(Inspector::assertAll(function ($link) {
+        return is_array($link) || isset($link['href']) && is_string($link['href']);
+      }, $links));
+      $this->links = $links;
+
+      $this->meta = $meta;
+
+      // Get an array of arrays of includes.
+      $this->includes = $includes;
+      // Flatten the includes.
+      if ($this->includes) {
+        array_walk($this->includes, [$this, 'addCacheableDependency']);
+      }
+    }
+    $this->documentType = $document_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    // Determine which of the two mutually exclusive top-level document members
+    // should be used.
+    $mutually_exclusive_member = $this->isErrorDocument() ? 'errors' : 'data';
+    $rasterized = [
+      $mutually_exclusive_member => [],
+      'jsonapi' => [
+        'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
+        'meta' => [
+          'links' => [
+            'self' => [
+              'href' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK,
+            ],
+          ],
+        ],
+      ],
+    ];
+    if (!empty($this->meta)) {
+      $rasterized['meta'] = $this->meta;
+    }
+
+    if ($this->isErrorDocument()) {
+      foreach ($this->values as $normalized_exception) {
+        $rasterized['errors'] = array_merge($rasterized['errors'], $normalized_exception->rasterizeValue());
+      }
+      return $rasterized;
+    }
+
+    $rasterized['links'] = $this->links;
+
+    $link_hash_salt = Crypt::randomBytesBase64();
+    foreach ($this->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->rasterizeValue() 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->rasterizeValue();
+        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 ($this->cardinality !== 1) {
+      $rasterized['data'] = array_filter($rasterized['data']);
+    }
+    else {
+      $rasterized['data'] = empty($rasterized['data']) ? NULL : reset($rasterized['data']);
+    }
+
+    if ($this->includes) {
+      $rasterized['included'] = array_map(function ($include) {
+        return $include->rasterizeValue();
+      }, $this->includes);
+    }
+
+    if (empty($rasterized['links'])) {
+      unset($rasterized['links']);
+    }
+
+    return $rasterized;
+  }
+
+  /**
+   * Whether this is an errors document or not.
+   *
+   * @return bool
+   *   TRUE if the document contains top-level errors, FALSE otherwise.
+   */
+  protected function isErrorDocument() {
+    return $this->documentType === static::ERROR_DOCUMENT;
+  }
+
+  /**
+   * 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/Value/NullFieldNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php
new file mode 100644
index 0000000..5746147
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+
+/**
+ * Normalizes null fields in accordance with the JSON:API specification.
+ *
+ * @internal
+ */
+class NullFieldNormalizerValue implements FieldNormalizerValueInterface {
+
+  use CacheableDependencyTrait;
+
+  /**
+   * The property type.
+   *
+   * @var mixed
+   */
+  protected $propertyType;
+
+  /**
+   * Instantiate a FieldNormalizerValue object.
+   *
+   * @param \Drupal\Core\Access\AccessResultInterface $field_access_result
+   *   The field access result.
+   * @param string $property_type
+   *   The property type of the field: 'attributes' or 'relationships'.
+   */
+  public function __construct(AccessResultInterface $field_access_result, $property_type) {
+    assert($property_type === 'attributes' || $property_type === 'relationships');
+    $this->setCacheability($field_access_result);
+
+    $this->propertyType = $property_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPropertyType() {
+    return $this->propertyType;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    return NULL;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php
new file mode 100644
index 0000000..f2b27f5
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Helps normalize relationship items in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class RelationshipItemNormalizerValue extends FieldItemNormalizerValue implements ValueExtractorInterface, CacheableDependencyInterface {
+
+  use CacheableDependenciesMergerTrait;
+
+  /**
+   * Resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * Instantiates a RelationshipItemNormalizerValue object.
+   *
+   * @param array $values
+   *   The values.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $values_cacheability
+   *   The cacheability of the normalized result. This cacheability is not part
+   *   of $values because field items are normalized by Drupal core's
+   *   serialization system, which was never designed with cacheability in mind.
+   *   FieldItemNormalizer::normalize() must catch the out-of-band bubbled
+   *   cacheability and then passes it to this value object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type of the target entity.
+   */
+  public function __construct(array $values, CacheableDependencyInterface $values_cacheability, ResourceType $resource_type) {
+    parent::__construct($values, $values_cacheability);
+    $this->resourceType = $resource_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (!$value = parent::rasterizeValue()) {
+      return $value;
+    }
+    $rasterized_value = [
+      'type' => $this->resourceType->getTypeName(),
+      'id' => empty($value['target_uuid']) ? $value : $value['target_uuid'],
+    ];
+
+    if (!empty($value['meta'])) {
+      $rasterized_value['meta'] = $value['meta'];
+    }
+
+    return $rasterized_value;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php
new file mode 100644
index 0000000..c191b94
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php
@@ -0,0 +1,192 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResultInterface;
+
+/**
+ * Helps normalize relationships in compliance with the JSON:API spec.
+ *
+ * @internal
+ */
+class RelationshipNormalizerValue extends FieldNormalizerValue {
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The JSON:API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The field name for the link generation.
+   *
+   * @var string
+   */
+  protected $fieldName;
+
+  /**
+   * The entity ID for the host entity.
+   *
+   * @var string
+   */
+  protected $hostEntityId;
+
+  /**
+   * Instantiate a EntityReferenceNormalizerValue object.
+   *
+   * @param \Drupal\Core\Access\AccessResultInterface $relationship_access_result
+   *   The relationship access result.
+   * @param RelationshipItemNormalizerValue[] $values
+   *   The normalized result.
+   * @param int $cardinality
+   *   The number of fields for the field list.
+   * @param array $link_context
+   *   All the objects and variables needed to generate the links for this
+   *   relationship.
+   */
+  public function __construct(AccessResultInterface $relationship_access_result, array $values, $cardinality, array $link_context) {
+    $this->hostEntityId = $link_context['host_entity_id'];
+    $this->fieldName = $link_context['field_name'];
+    $this->linkManager = $link_context['link_manager'];
+    $this->resourceType = $link_context['resource_type'];
+    array_walk($values, function ($field_item_value) {
+      if (!$field_item_value instanceof RelationshipItemNormalizerValue) {
+        throw new \RuntimeException(sprintf('Unexpected normalizer item value for this %s.', get_called_class()));
+      }
+    });
+    parent::__construct($relationship_access_result, $values, $cardinality, 'relationships');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    $links = $this->getLinks($this->fieldName);
+    // 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 = parent::rasterizeValue() ?: [];
+
+    if ($this->cardinality === 1) {
+      return empty($data)
+        ? ['data' => NULL, 'links' => $links]
+        : ['data' => $data, 'links' => $links];
+    }
+    else {
+      return ['data' => static::ensureUniqueResourceIdentifierObjects($data), 'links' => $links];
+    }
+  }
+
+  /**
+   * Ensures each resource identifier object is unique.
+   *
+   * The official JSON:API JSON-Schema document requires that no two resource
+   * identifier objects are duplicated.
+   *
+   * This adds an @code arity @endcode member to each object's
+   * @code meta @endcode member. 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.
+   *
+   * @param array $resource_identifier_objects
+   *   A list of JSON:API resource identifier objects.
+   *
+   * @return array
+   *   A set of JSON:API resource identifier objects, with those having multiple
+   *   occurrences getting [meta][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
+   */
+  public static function ensureUniqueResourceIdentifierObjects(array $resource_identifier_objects) {
+    if (count($resource_identifier_objects) <= 1) {
+      return $resource_identifier_objects;
+    }
+
+    // Count each repeated resource identifier and track their array indices.
+    $analysis = [];
+    foreach ($resource_identifier_objects as $index => $rio) {
+      $composite_key = $rio['type'] . ':' . $rio['id'];
+
+      $analysis[$composite_key]['count'] = isset($analysis[$composite_key])
+        ? $analysis[$composite_key]['count'] + 1
+        : 0;
+
+      // The index will later be used to assign an arity to repeated resource
+      // identifier objects. Doing this in two phases prevents adding an arity
+      // to objects which only occur once.
+      $analysis[$composite_key]['indices'][] = $index;
+    }
+
+    // Assign an arity to objects whose type + ID pair occurred more than once.
+    foreach ($analysis as $computed) {
+      if ($computed['count'] > 0) {
+        foreach ($computed['indices'] as $arity => $index) {
+          $resource_identifier_objects[$index]['meta']['arity'] = $arity;
+        }
+      }
+    }
+
+    return $resource_identifier_objects;
+  }
+
+  /**
+   * Gets the links for the relationship.
+   *
+   * @param string $field_name
+   *   The field name for the relationship.
+   *
+   * @return array
+   *   An array of links to be rasterized.
+   */
+  protected function getLinks($field_name) {
+    $relationship_field_name = $this->resourceType->getPublicName($field_name);
+    $route_parameters = [
+      'related' => $relationship_field_name,
+    ];
+    $links['self']['href'] = $this->linkManager->getEntityLink(
+      $this->hostEntityId,
+      $this->resourceType,
+      $route_parameters,
+      "$relationship_field_name.relationship.get"
+    );
+    $resource_types = $this->resourceType->getRelatableResourceTypesByField($field_name);
+    if (static::hasNonInternalResourceType($resource_types)) {
+      $links['related']['href'] = $this->linkManager->getEntityLink(
+        $this->hostEntityId,
+        $this->resourceType,
+        $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/Value/ValueExtractorInterface.php b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php
new file mode 100644
index 0000000..e3b7d04
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * Interface for value objects used in the JSON:API normalization process.
+ *
+ * @internal
+ */
+interface ValueExtractorInterface {
+
+  /**
+   * Get the rasterized value.
+   *
+   * @return mixed
+   *   The value.
+   */
+  public function rasterizeValue();
+
+}
diff --git a/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
new file mode 100644
index 0000000..c1cbc52
--- /dev/null
+++ b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
@@ -0,0 +1,56 @@
+<?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;
+    if ($storage = $entity_type_manager->getStorage($entity_type_id)) {
+      if (!$entities = $storage->loadByProperties(['uuid' => $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 0000000..779e609
--- /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 0000000..8fd6fb1
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/EntityCondition.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * A condition object for the EntityQuery.
+ *
+ * @internal
+ */
+class EntityCondition {
+
+  /**
+   * 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/EntityConditionGroup.php b/core/modules/jsonapi/src/Query/EntityConditionGroup.php
new file mode 100644
index 0000000..c90835a
--- /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 0000000..e906a7a
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/Filter.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Entity\Query\QueryInterface;
+
+/**
+ * Gathers information about the filter parameter.
+ *
+ * @internal
+ */
+class Filter {
+
+  /**
+   * The JSON:API filter key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'filter';
+
+  /**
+   * 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\Entity\Query\QueryInterface $query
+   *   The query for which the condition should be constructed.
+   *
+   * @return \Drupal\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\Entity\Query\QueryInterface $query
+   *   The query to which the filter should be applied.
+   * @param \Drupal\Entity\Query\EntityConditionGroup $condition_group
+   *   The condition group to build.
+   *
+   * @return \Drupal\Entity\Query\QueryInterface
+   *   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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/OffsetPage.php b/core/modules/jsonapi/src/Query/OffsetPage.php
new file mode 100644
index 0000000..ad66521
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/OffsetPage.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/Sort.php b/core/modules/jsonapi/src/Query/Sort.php
new file mode 100644
index 0000000..ab6da70
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/Sort.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceResponse.php b/core/modules/jsonapi/src/ResourceResponse.php
new file mode 100644
index 0000000..f3a1bf1
--- /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 0000000..2b432cf
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceType.php
@@ -0,0 +1,368 @@
+<?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
+ *
+ * @deprecated
+ */
+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;
+
+  /**
+   * 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 if https://www.drupal.org/project/drupal/issues/2949021 lands, then `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;
+  }
+
+  /**
+   * 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 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, 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->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 0000000..5d2665e
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
@@ -0,0 +1,419 @@
+<?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) {
+      $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions());
+      $resource_types = [];
+      foreach ($entity_type_ids as $entity_type_id) {
+        $resource_types = array_merge($resource_types, array_map(function ($bundle) use ($entity_type_id) {
+          $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+          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::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;
+  }
+
+  /**
+   * 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 0000000..faf6928
--- /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/Routing/JsonApiParamEnhancer.php b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
new file mode 100644
index 0000000..d016198
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\EnhancerInterface;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\Query\Sort;
+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 JsonApiParamEnhancer 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_do_not_use_removal_imminent');
+    }
+    return $this->serializer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    if (!Routes::isJsonApiRequest($defaults)) {
+      return $defaults;
+    }
+
+    $options = [];
+
+    $resource_type = Routes::getResourceTypeNameFromParameters($defaults);
+    $context = [
+      'entity_type_id' => $resource_type->getEntityTypeId(),
+      'bundle' => $resource_type->getBundle(),
+    ];
+
+    if ($request->query->has('sort')) {
+      $sort = $request->query->get('sort');
+      $options['sort'] = $this->serializer()->denormalize($sort, Sort::class, NULL, $context);
+    }
+
+    $options['page'] = $request->query->has('page')
+      ? $this->serializer()->denormalize($request->query->get('page'), OffsetPage::class)
+      : new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX);
+
+    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']));
+      }
+    }
+
+    $defaults['_json_api_params'] = $options;
+
+    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/RouteEnhancer.php b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
new file mode 100644
index 0000000..d2e71f5
--- /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 0000000..fe1c76b
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Routes.php
@@ -0,0 +1,384 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\jsonapi\Access\RelationshipFieldAccess;
+use Drupal\jsonapi\Controller\EntryPoint;
+use Drupal\jsonapi\Normalizer\Relationship;
+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();
+
+    // 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));
+    }
+    $routes->add('jsonapi.resource_list', static::getEntryPointRoute($this->jsonApiBasePath));
+
+    // 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']);
+
+    // Require the JSON:API media type header on every route.
+    $routes->addRequirements(['_content_type_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;
+  }
+
+  /**
+   * 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' => Relationship::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.
+   */
+  protected static function getRouteName(ResourceType $resource_type, $route_type) {
+    return sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $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);
+  }
+
+  /**
+   * 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 0000000..0ea0442
--- /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/src/StackMiddleware/FormatSetter.php b/core/modules/jsonapi/src/StackMiddleware/FormatSetter.php
new file mode 100644
index 0000000..4896e9e
--- /dev/null
+++ b/core/modules/jsonapi/src/StackMiddleware/FormatSetter.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\jsonapi\StackMiddleware;
+
+use Symfony\Component\HttpFoundation\AcceptHeader;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+
+/**
+ * Sets the 'api_json' for requests with a JSON:API Content-Type header.
+ *
+ * @internal
+ */
+final class FormatSetter implements HttpKernelInterface {
+
+  /**
+   * The wrapped HTTP kernel.
+   *
+   * @var \Symfony\Component\HttpKernel\HttpKernelInterface
+   */
+  protected $httpKernel;
+
+  /**
+   * Constructs a FormatSetter object.
+   *
+   * @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
+   *   The decorated kernel.
+   */
+  public function __construct(HttpKernelInterface $http_kernel) {
+    $this->httpKernel = $http_kernel;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = TRUE) {
+    $accepted = AcceptHeader::fromString($request->headers->get('Accept'));
+    if ($accepted->get('application/vnd.api+json')) {
+      $request->setRequestFormat('api_json');
+    }
+    return $this->httpKernel->handle($request, $type, $catch);
+  }
+
+}
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 0000000..a5664b7
--- /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 0000000..8ca04a7
--- /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 0000000..9484d19
--- /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 0000000..33c9c1a
--- /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 0000000..a72ccb1
--- /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 0000000..2ad6cd9
--- /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 0000000..9dd4851
--- /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 0000000..89e5967
--- /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 0000000..79b44fc
--- /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 0000000..4cfaf77
--- /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 0000000..20f2de8
--- /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 0000000..0ffa03d
--- /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 0000000..c8ce4b8
--- /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 0000000..30944db
--- /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 0000000..ebb51ed
--- /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 0000000..2f3f70e
--- /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 0000000..b8ab647
--- /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 0000000..347683c
--- /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 0000000..796494c
--- /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 0000000..98c56d0
--- /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 0000000..3168383
--- /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 0000000..0abe728
--- /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 0000000..452b5ef
--- /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(['user.roles']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getExpectedCollectionCacheability(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $filtered = FALSE) {
+    return parent::getExpectedCollectionCacheability($collection, $sparse_fieldset, $account, $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 0000000..7b551e7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/CommentTest.php
@@ -0,0 +1,444 @@
+<?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.",
+    // @todo These are relationships, and cannot be tested in the same way. Fix in https://www.drupal.org/project/jsonapi/issues/2939810.
+    // 'pid' => NULL,
+    // 'entity_id' => NULL,
+    // 'uid' => NULL,
+    '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,
+  ];
+
+  /**
+   * {@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);
+      $this->assertResourceErrorResponse(422, 'entity_id: This value should not be null.', NULL, $response, '/data/attributes/entity_id');
+    }
+    catch (\Exception $e) {
+      $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
+    }
+
+    // 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(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($collection, $sparse_fieldset, $account, $filtered);
+    if ($filtered) {
+      $cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
+    }
+    return $cacheability;
+  }
+
+}
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 0000000..26ce5ba
--- /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 0000000..e6d873d
--- /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 0000000..3aa4797
--- /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 0000000..993cff5
--- /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 0000000..05cc0ca
--- /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(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($collection, $sparse_fieldset, $account, $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 0000000..5c1dd99
--- /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 0000000..ab6f727
--- /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 0000000..b28efdb
--- /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 0000000..9a1c36d
--- /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 0000000..b78f96c
--- /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 0000000..3b88a0b
--- /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(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($collection, $sparse_fieldset, $account, $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 0000000..1b154cc
--- /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 0000000..991b35f
--- /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/ExternalNormalizersTest.php b/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php
new file mode 100644
index 0000000..c3c82c8
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php
@@ -0,0 +1,198 @@
+<?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);
+    // @todo Make this unconditional once https://www.drupal.org/project/drupal/issues/2957385 lands — JSON:API fixed denormalization of properties in https://www.drupal.org/project/jsonapi/issues/2955615, core's Serialization module still has to follow
+    if ($test_module === 'jsonapi_test_field_type') {
+      $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']);
+    $entities = $this->container->get('entity_type.manager')
+      ->getStorage('entity_test')
+      ->loadByProperties(['uuid' => $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 0000000..5ca4f90
--- /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 0000000..311c098
--- /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 0000000..0f75906
--- /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 0000000..ff67417
--- /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/FilterFormatTest.php b/core/modules/jsonapi/tests/src/Functional/FilterFormatTest.php
new file mode 100644
index 0000000..8af68bb
--- /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 0000000..55193d8
--- /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 0000000..e170222
--- /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 0000000..6e3a48f
--- /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 0000000..64dad31
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalMultilingualTest.php
@@ -0,0 +1,72 @@
+<?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);
+
+    // Test reading an individual entity.
+    $output = Json::decode($this->drupalGet('/ca/jsonapi/node/article/' . $this->nodes[0]->uuid(), ['query' => ['include' => 'field_tags,field_image']]));
+    $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()));
+    $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'));
+    $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 0000000..e25e4a0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
@@ -0,0 +1,868 @@
+<?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();
+
+    // 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'));
+    $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]],
+    ]));
+    $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]],
+    ]));
+    $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,
+        ],
+      ],
+    ]));
+    $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.
+    $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']);
+    $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]],
+    ]));
+    $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],
+    ]);
+    $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'],
+    ]));
+    $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],
+    ]));
+    $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],
+    ]));
+    $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],
+    ]));
+    $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],
+    ]));
+    $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],
+    ]));
+    $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],
+    ]));
+    $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 0000000..87ab13e
--- /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 0000000..321f500
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -0,0 +1,768 @@
+<?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']);
+  }
+
+}
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 0000000..e812afe
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MediaTest.php
@@ -0,0 +1,355 @@
+<?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 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 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 0000000..3e2a015
--- /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 0000000..2e6b04c
--- /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 0000000..2acb15f
--- /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 0000000..89a4ccd
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MessageTest.php
@@ -0,0 +1,179 @@
+<?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'));
+  }
+
+}
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 0000000..51834e2
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -0,0 +1,435 @@
+<?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,
+    // @todo This is a relationship, and cannot be tested in the same way. Fix in https://www.drupal.org/project/jsonapi/issues/2939810.
+    // 'revision_uid' => 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'.",
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+        break;
+
+      case 'PATCH':
+        // Do not grant the 'create url aliases' permission to test the case
+        // when the path field is protected/not accessible, see
+        // \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase
+        // for a positive test.
+        $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!NodeType::load('camelids')) {
+      // Create a "Camelids" node type.
+      NodeType::create([
+        'name' => 'Camelids',
+        'type' => 'camelids',
+      ])->save();
+    }
+
+    // Create a "Llama" node.
+    $node = Node::create(['type' => 'camelids']);
+    $node->setTitle('Llama')
+      ->setOwnerId($this->account->id())
+      ->setPublished()
+      ->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);
+    // @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 PATCH the selected field (path). The following permissions are required: 'create url aliases' OR 'administer url aliases'.",
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data/attributes/path',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $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'.", $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'],
+      ['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 0000000..eeeb464
--- /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 0000000..2570e97
--- /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 0000000..7c76bc7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -0,0 +1,642 @@
+<?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.
+    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',
+          ],
+        ],
+      ];
+    }
+
+    return static::decorateExpectedResponseForIncludedFields(ResourceResponse::create($individual_document), $resource_data['responses']);
+  }
+
+  /**
+   * 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']))
+      ->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 static function getEmptyCollectionResponse($cardinality, $self_link) {
+    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]],
+    ]);
+  }
+
+  /**
+   * 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 0000000..f70ffbe
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,2826 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Behat\Mink\Driver\BrowserKitDriver;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\Random;
+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\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+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\NullEntityCollection;
+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\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;
+
+  /**
+   * {@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 serializer service.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('jsonapi.serializer_do_not_use_removal_imminent');
+
+    // 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.
+    $this->entityStorage = $this->container->get('entity_type.manager')->getStorage(static::$entityTypeId);
+    $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) {
+    $doc = new JsonApiDocumentTopLevel($entity, new NullEntityCollection(), [
+      'self' => [
+        'href' => $url->toString(TRUE)->getGeneratedUrl(),
+      ],
+    ]);
+    return $this->serializer->normalize($doc, 'api_json', [
+      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+      'account' => $this->account,
+    ])->rasterizeValue();
+  }
+
+  /**
+   * 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(['user.permissions']);
+  }
+
+  /**
+   * 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) {
+    return [
+      // Cache contexts for JSON:API URL query parameters.
+      'url.query_args:fields',
+      'url.query_args:include',
+      // Drupal defaults.
+      'url.site',
+      'user.permissions',
+    ];
+  }
+
+  /**
+   * Computes the cacheability for a given entity collection.
+   *
+   * @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 \Drupal\Core\Session\AccountInterface $account
+   *   An account for which cacheability should be computed (cacheability is
+   *   dependent on access).
+   * @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(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $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());
+    $cacheability->addCacheTags(['http_response']);
+    $cacheability->addCacheTags(reset($collection)->getEntityType()->getListCacheTags());
+    $cacheability->addCacheContexts([
+      // 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',
+    ]);
+    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);
+
+  /**
+   * 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();
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * 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;
+  }
+
+  /**
+   * 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']);
+      $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'MISS');
+    }
+
+    $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'], FALSE, 'MISS');
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'MISS');
+    $head_headers = $response->getHeaders();
+
+    // 200 for well-formed GET request. Page Cache hit because of HEAD request.
+    // Same for Dynamic Page Cache hit.
+    $response = $this->request('GET', $url, $request_options);
+
+    $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'HIT');
+    // Assert that Dynamic Page Cache did not store a ResourceResponse object,
+    // which needs serialization after every cache hit. Instead, it should
+    // contain a flattened response. Otherwise performance suffers.
+    // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
+    $cache_items = $this->container->get('database')
+      ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
+        ':pattern' => '%[route]=jsonapi.%',
+      ])
+      ->fetchAllAssoc('cid');
+    $this->assertTrue(count($cache_items) >= 2);
+    $found_cache_redirect = FALSE;
+    $found_cached_200_response = FALSE;
+    $other_cached_responses_are_4xx = TRUE;
+    foreach ($cache_items as $cid => $cache_item) {
+      $cached_data = unserialize($cache_item->data);
+      if (!isset($cached_data['#cache_redirect'])) {
+        $cached_response = $cached_data['#response'];
+        if ($cached_response->getStatusCode() === 200) {
+          $found_cached_200_response = TRUE;
+        }
+        elseif (!$cached_response->isClientError()) {
+          $other_cached_responses_are_4xx = FALSE;
+        }
+        $this->assertNotInstanceOf(ResourceResponse::class, $cached_response);
+        $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
+      }
+      else {
+        $found_cache_redirect = TRUE;
+      }
+    }
+    $this->assertTrue($found_cache_redirect);
+    $this->assertTrue($found_cached_200_response);
+    $this->assertTrue($other_cached_responses_are_4xx);
+
+    // 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'], [''], 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'], [''], 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());
+
+    // 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);
+    // MISS or UNCACHEABLE depends on the collection 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);
+
+    $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.mail',
+            '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',
+        '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 `mail` field, given in the path `field_jsonapi_test_entity_ref.entity:user.mail`.";
+      $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);
+    $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', array_merge($entity_collection_filter, $include));
+    $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', array_merge($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($collection, NULL, $this->account, $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];
+      // @todo uncomment this assertion in https://www.drupal.org/project/jsonapi/issues/2929428
+      // Dynamic Page Cache miss because cache should vary based on the
+      // 'include' query param.
+      // @codingStandardsIgnoreStart
+      //$expected_cacheability = $expected_resource_response->getCacheableMetadata();
+      //$this->assertResourceResponse(
+      //  $expected_resource_response->getStatusCode(),
+      //  $expected_document,
+      //  $actual_response,
+      //  $expected_cacheability->getCacheTags(),
+      //  \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cacheability->getCacheContexts()),
+      //  FALSE,
+      //  $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'
+      //);
+      // @codingStandardsIgnoreEnd
+      $this->assertSame($expected_resource_response->getStatusCode(), $actual_response->getStatusCode());
+      $expected_document = $expected_resource_response->getResponseData();
+      $actual_document = Json::decode((string) $actual_response->getBody());
+      $this->assertSameDocument($expected_document, $actual_document);
+    }
+  }
+
+  /**
+   * 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 = 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);
+    $expected_cacheability = (new CacheableMetadata())
+      ->addCacheTags($this->getExpectedCacheTags([]))
+      ->addCacheContexts(['url.site'])
+      ->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 mixed
+   *   An array of expected ResourceResponses, keyed by thier relationship field
+   *   name.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
+    $entity = $entity ?: $this->entity;
+    // Get the relationships responses which contain resource identifiers for
+    // every related resource.
+    $relationship_responses = array_map(function ($relationship_field_name) use ($entity) {
+      return $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity);
+    }, array_combine($relationship_field_names, $relationship_field_names));
+    $base_resource_identifier = static::toResourceIdentifier($entity);
+    $expected_related_responses = [];
+    foreach ($relationship_field_names as $relationship_field_name) {
+      $internal_name = $this->resourceType->getInternalName($relationship_field_name);
+      $access = 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 = $relationship_responses[$relationship_field_name];
+        $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'])) {
+          $related_response = isset($relationship_document['errors'])
+            ? $relationship_response
+            : static::getEmptyCollectionResponse(!is_null($relationship_document['data']), $self_link);
+        }
+        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);
+          }
+        }
+      }
+      $expected_related_responses[$relationship_field_name] = $related_response;
+    }
+    return $expected_related_responses ?: [];
+  }
+
+  /**
+   * 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();
+    // @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' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'source' => [
+            'pointer' => '/data/attributes/' . $label_field,
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $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/node/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;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands.
+    // @codingStandardsIgnoreStart
+    /*
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
+    */
+    // @codingStandardsIgnoreEnd
+
+    $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);
+      // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
+      // its body unconditionally.
+      if (static::$entityTypeId !== 'taxonomy_term') {
+        $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(['uuid' => $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();
+    // @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' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'source' => [
+            'pointer' => '/data/attributes/' . $label_field,
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $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);
+    // @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 PATCH the selected field (field_rest_test).",
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data/attributes/field_rest_test',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $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');
+    // @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 PATCH the selected field ($id_field_name). The entity ID cannot be changed.",
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data/attributes/' . $id_field_name,
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ($id_field_name). The entity ID cannot be changed", $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);
+    // @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 PATCH the selected field (field_rest_test).",
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data/attributes/field_rest_test',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $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);
+      // @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 PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''),
+            'links' => [
+              'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+              'via' => ['href' => $url->setAbsolute()->toString()],
+            ],
+            'source' => [
+              'pointer' => '/data/attributes/' . $patch_protected_field_name,
+            ],
+          ],
+        ],
+      ];
+      $this->assertResourceResponse(403, $expected_document, $response);
+      /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $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 EntityReferenceItem::class:
+          // EntityReferenceItem::generateSampleValue() picks one of the last 50
+          // entities of the supported type & bundle. We don't care if the value
+          // is valid, we only care that it's different.
+          $field->setValue(['target_id' => 99999]);
+          break;
+
+        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.
+      $this->assertResourceResponse(
+        200,
+        $expected_document,
+        $response,
+        $expected_cacheability->getCacheTags(),
+        $expected_cacheability->getCacheContexts(),
+        FALSE,
+        'MISS'
+      );
+    }
+    // Test Dynamic Page Cache hit for a query with the same field set.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'HIT');
+  }
+
+  /**
+   * 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)) {
+      $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);
+      $response_document = Json::decode((string) $actual_response->getBody());
+      $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options);
+      $expected_document = $expected_response->getResponseData();
+      // @todo uncomment this assertion in https://www.drupal.org/project/jsonapi/issues/2929428
+      // Dynamic Page Cache miss because cache should vary based on the
+      // 'include' query param.
+      // @codingStandardsIgnoreStart
+      // $expected_cacheability = $expected_response->getCacheableMetadata();
+      // $this->assertResourceResponse(
+      //   200,
+      //   FALSE,
+      //   $actual_response,
+      //   $expected_cacheability->getCacheTags(),
+      //   \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cacheability->getCacheContexts()),
+      //   FALSE,
+      //   $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'
+      // );
+      // @codingStandardsIgnoreEnd
+      $this->assertSameDocument($expected_document, $response_document);
+    }
+  }
+
+  /**
+   * 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 [];
+  }
+
+  /**
+   * 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 0000000..298dc48
--- /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/RestJsonApiUnsupported.php b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
new file mode 100644
index 0000000..24a0109
--- /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', '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 0000000..f44ebd0
--- /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 0000000..4f8d609
--- /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 0000000..4f741d6
--- /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 0000000..0ab41a3
--- /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 0000000..26bdaf6
--- /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(array $collection, array $sparse_fieldset = NULL, AccountInterface $account, $filtered = FALSE) {
+    $cacheability = parent::getExpectedCollectionCacheability($collection, $sparse_fieldset, $account, $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 0000000..cce7732
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TermTest.php
@@ -0,0 +1,484 @@
+<?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\jsonapi\ResourceResponse;
+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 getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) {
+    $responses = parent::getExpectedRelatedResponses($relationship_field_names, $request_options, $entity);
+    if ($responses['parent']->getStatusCode() === 404) {
+      $responses['parent'] = new ResourceResponse([
+        'data' => [],
+        'jsonapi' => [
+          'meta' => [
+            'links' => [
+              'self' => ['href' => 'http://jsonapi.org/format/1.0/'],
+            ],
+          ],
+          'version' => '1.0',
+        ],
+        'links' => [
+          'self' => [
+            'href' => static::getRelatedLink(static::toResourceIdentifier($this->entity), 'parent'),
+          ],
+        ],
+      ]);
+    }
+    return $responses;
+  }
+
+  /**
+   * {@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 0000000..36d1981
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TestCoverageTest.php
@@ -0,0 +1,117 @@
+<?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 0000000..e6d3488
--- /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 0000000..926c314
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php
@@ -0,0 +1,600 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+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 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);
+    // @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' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'mail: Your current password is missing or incorrect; it\'s required to change the Email.',
+          'source' => [
+            'pointer' => '/data/attributes/mail',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'mail: Your current password is missing or incorrect; it\'s required to change the Email.', $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->assertResourceResponse(422, $expected_document, $response);
+
+    $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);
+    // @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' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'pass: Your current password is missing or incorrect; it\'s required to change the Password.',
+          'source' => [
+            'pointer' => '/data/attributes/pass',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'pass: Your current password is missing or incorrect; it\'s required to change the Password.', $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);
+    // @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 PATCH the selected field (name).',
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'source' => [
+            'pointer' => '/data/attributes/name',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, 'Forbidden', 'The current user is not allowed to PATCH the selected field (name).', $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 = 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());
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to PATCH the selected field (uid). The entity ID cannot be changed',
+          'links' => [
+            'info' => ['href' => HttpExceptionNormalizer::getInfoUrl(403)],
+            'via' => ['href' => $url->setAbsolute()->toString()],
+          ],
+          'id' => '/user--user/' . $this->account->uuid(),
+          'source' => [
+            'pointer' => '/data/attributes/uid',
+          ],
+        ],
+      ],
+    ];
+    // @todo Uncomment this assertion in https://www.drupal.org/project/jsonapi/issues/2939810.
+    // $this->assertResourceResponse(403, $expected_document, $response);
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    /* $this->assertResourceErrorResponse(403, 'Forbidden', 'The current user is not allowed to PATCH the selected field (uid). The entity ID cannot be changed', $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');
+    // @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);
+    $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'], ['url.path', 'url.query_args:filter'], FALSE, 'MISS');
+  }
+
+  /**
+   * Tests that the collection contains the anonymous user.
+   */
+  public function testCollectionContainsAnonymousUser() {
+    $url = Url::fromRoute('jsonapi.user--user.collection');
+    $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 0000000..acd8f86
--- /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 0000000..61ecdc8
--- /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 0000000..6ad93d5
--- /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 0000000..50aae4a
--- /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 0000000..7a4e309
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -0,0 +1,713 @@
+<?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\ResourceType\ResourceType;
+use Drupal\jsonapi\Controller\EntityResource;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+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\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')
+    );
+  }
+
+  /**
+   * @covers ::getIndividual
+   */
+  public function testGetIndividual() {
+    $response = $this->entityResource->getIndividual($this->node, new Request());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertEquals(1, $response->getResponseData()->getData()->id());
+  }
+
+  /**
+   * @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, new Request());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetCollection() {
+    $request = new Request([], [], [
+      '_route_params' => ['_json_api_params' => []],
+      '_json_api_params' => [],
+    ]);
+
+    // 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(1, $response->getResponseData()->getData()->getIterator()->current()->id());
+    $this->assertEquals([
+      'node:1',
+      'node:2',
+      'node:3',
+      'node:4',
+      'node_list',
+    ], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetFilteredCollection() {
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('type', 'article')]));
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'filter' => $filter,
+        ],
+      ],
+      '_json_api_params' => [
+        'filter' => $filter,
+      ],
+    ]);
+
+    $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')
+    );
+
+    // 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() {
+    $sort = new Sort([['path' => 'type', 'direction' => 'DESC']]);
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'sort' => $sort,
+        ],
+      ],
+      '_json_api_params' => [
+        'sort' => $sort,
+      ],
+    ]);
+
+    $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')
+    );
+
+    // 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());
+    $this->assertEquals($response->getResponseData()->getData()->toArray()[0]->id(), '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() {
+    $pager = new OffsetPage(1, 1);
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'page' => $pager,
+        ],
+      ],
+      '_json_api_params' => [
+        'page' => $pager,
+      ],
+    ]);
+
+    $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')
+    );
+
+    // 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(2, $data->toArray()[0]->id());
+    $this->assertEquals(['node:2', 'node_list'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetEmptyCollection() {
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('uuid', 'invalid')]));
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'filter' => $filter,
+        ],
+      ],
+      '_json_api_params' => [
+        'filter' => $filter,
+      ],
+    ]);
+
+    // 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', new Request());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(User::class, $response->getResponseData()->getData()->toArray()[0]);
+    $this->assertEquals(1, $response->getResponseData()->getData()->toArray()[0]->id());
+    $this->assertEquals(['node:1'], $response->getCacheableMetadata()->getCacheTags());
+    // to-many relationship.
+    $response = $this->entityResource->getRelated($resource_type, $this->node4, 'field_relationships', new Request());
+    $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', new Request());
+    $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, new Request());
+    // As a side effect, the node will also be saved.
+    $this->assertNotEmpty($node->id());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertEquals(5, $response->getResponseData()->getData()->id());
+    $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, new Request());
+  }
+
+  /**
+   * @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, new Request());
+  }
+
+  /**
+   * @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 = new Request([], [], [], [], [], [], $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(Node::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)],
+    ]);
+    $response = $this->entityResource->addToRelationshipData($resource_type, $this->node, 'field_relationships', $resource_identifiers, new 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)],
+    ]);
+    $response = $this->entityResource->replaceRelationshipData($resource_type, $this->node, 'field_relationships', $relationships, new 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)],
+    ]);
+    $response = $this->entityResource->removeFromRelationshipData($resource_type, $this->node, 'field_relationships', $deleted_rels, new 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/Controller/EntryPointTest.php b/core/modules/jsonapi/tests/src/Kernel/Controller/EntryPointTest.php
new file mode 100644
index 0000000..323c084
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntryPointTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Controller;
+
+use Drupal\jsonapi\Controller\EntryPoint;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Controller\EntryPoint
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class EntryPointTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'jsonapi',
+    'serialization',
+    'system',
+    'user',
+  ];
+
+  /**
+   * @covers ::index
+   */
+  public function testIndex() {
+    $controller = new EntryPoint(
+      \Drupal::service('jsonapi.resource_type.repository'),
+      \Drupal::service('renderer'),
+      \Drupal::service('current_user')
+    );
+    $processed_response = $controller->index();
+    $this->assertEquals(
+      [
+        'url.site',
+        'user.roles:authenticated',
+      ],
+      $processed_response->getCacheableMetadata()->getCacheContexts()
+    );
+    $links = $processed_response->getResponseData()->getLinks();
+    $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->assertSame([], $processed_response->getResponseData()->getMeta());
+  }
+
+}
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 0000000..e53c43d
--- /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/EntityConditionGroupNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
new file mode 100644
index 0000000..8082d65
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityConditionGroupNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($case) {
+    $normalizer = new EntityConditionGroupNormalizer();
+
+    $normalized = $normalizer->denormalize($case, EntityConditionGroup::class);
+
+    $this->assertEquals($case['conjunction'], $normalized->conjunction());
+
+    foreach ($normalized->members() as $key => $condition) {
+      $this->assertEquals($case['members'][$key]['path'], $condition->field());
+      $this->assertEquals($case['members'][$key]['value'], $condition->value());
+    }
+  }
+
+  /**
+   * @covers ::denormalize
+   */
+  public function testDenormalizeException() {
+    $normalizer = new EntityConditionGroupNormalizer();
+    $data = ['conjunction' => 'NOT_ALLOWED', 'members' => []];
+    $this->setExpectedException(\InvalidArgumentException::class);
+    $normalized = $normalizer->denormalize($data, EntityConditionGroup::class);
+  }
+
+  /**
+   * Data provider for testDenormalize.
+   */
+  public function denormalizeProvider() {
+    return [
+      [['conjunction' => 'AND', 'members' => []]],
+      [['conjunction' => 'OR', 'members' => []]],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php
new file mode 100644
index 0000000..2dc607e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityConditionNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityConditionNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = new EntityConditionNormalizer();
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($case) {
+    $normalized = $this->normalizer->denormalize($case, EntityCondition::class);
+    $this->assertEquals($case['path'], $normalized->field());
+    $this->assertEquals($case['value'], $normalized->value());
+    if (isset($case['operator'])) {
+      $this->assertEquals($case['operator'], $normalized->operator());
+    }
+  }
+
+  /**
+   * Data provider for testDenormalize.
+   */
+  public function denormalizeProvider() {
+    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 ::denormalize
+   * @dataProvider denormalizeValidationProvider
+   */
+  public function testDenormalizeValidation($input, $exception) {
+    if ($exception) {
+      $this->setExpectedException(get_class($exception), $exception->getMessage());
+    }
+    $this->normalizer->denormalize($input, EntityCondition::class);
+  }
+
+  /**
+   * Data provider for denormalizeProvider.
+   */
+  public function denormalizeValidationProvider() {
+    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 '" . EntityConditionNormalizer::VALUE_KEY . "' key."),
+      ],
+      [
+        ['value' => 'value_only'],
+        new BadRequestHttpException("Filter parameter is missing a '" . EntityConditionNormalizer::PATH_KEY . "' key."),
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/FilterNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
new file mode 100644
index 0000000..43abf45
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer;
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Drupal\jsonapi\Normalizer\FilterNormalizer;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Context\FieldResolver;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\FilterNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ * @group legacy
+ *
+ * @internal
+ */
+class FilterNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = new FilterNormalizer(
+      $this->getFieldResolver('foo', 'bar'),
+      new EntityConditionNormalizer(),
+      new EntityConditionGroupNormalizer()
+    );
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($normalized, $expected) {
+    $actual = $this->normalizer->denormalize($normalized, Filter::class, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']);
+    $conditions = $actual->root()->members();
+    for ($i = 0; $i < count($normalized); $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 testDenormalize.
+   */
+  public function denormalizeProvider() {
+    return [
+      'shorthand' => [
+        ['uid' => ['value' => 1]],
+        [['path' => 'uid', 'value' => 1, 'operator' => '=']],
+      ],
+      'extreme shorthand' => [
+        ['uid' => 1],
+        [['path' => 'uid', 'value' => 1, 'operator' => '=']],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   */
+  public function testDenormalizeNested() {
+    $normalized = [
+      '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',
+        ],
+      ],
+    ];
+    $filter = $this->normalizer->denormalize($normalized, Filter::class, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']);
+    $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 getFieldResolver($entity_type_id, $bundle) {
+    $field_resolver = $this->prophesize(FieldResolver::class);
+    $field_resolver->resolveInternalEntityQueryPath('foo', 'bar', Argument::any())->willReturnArgument(2);
+    return $field_resolver->reveal();
+  }
+
+}
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 0000000..aa6d3f1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,763 @@
+<?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\NullEntityCollection;
+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');
+    $link_manager
+      ->getRequestLink(Argument::any())
+      ->willReturn('dummy_document_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');
+
+    $includes = $this->includeResolver->resolve($resource_type, $this->node, 'uid,field_tags,field_image');
+
+    $jsonapi_doc_object = $this
+      ->getNormalizer()
+      ->normalize(
+        new JsonApiDocumentTopLevel($this->node, $includes, []),
+        '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->rasterizeValue();
+
+    // @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->rasterizeValue();
+    $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');
+    $include_param = 'uid,field_tags';
+    $includes = $this->includeResolver->resolve($resource_type, $this->node, $include_param);
+    $document_wrapper = new JsonApiDocumentTopLevel($this->node, $includes, []);
+
+    $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->rasterizeValue();
+    $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_do_not_use_removal_imminent')
+      ->serialize(
+        new JsonApiDocumentTopLevel(new ErrorCollection([new BadRequestHttpException('Lorem')]), new NullEntityCollection(), []),
+        'api_json',
+        []
+      );
+    $normalized = Json::decode($normalized);
+    $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');
+    $document_wrapper = new JsonApiDocumentTopLevel($this->nodeType, new NullEntityCollection(), []);
+
+    $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->rasterizeValue();
+    $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('\Drupal\node\Entity\Node', $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('\Drupal\node\Entity\Node', $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');
+    $context = [
+      'resource_type' => $resource_type,
+      'account' => NULL,
+    ];
+    $jsonapi_doc_object = $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel($this->node, new NullEntityCollection(), []), '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_do_not_use_removal_imminent'));
+    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/Normalizer/OffsetPageNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
new file mode 100644
index 0000000..6a07040
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\jsonapi\Normalizer\OffsetPageNormalizer;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\OffsetPage;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\OffsetPageNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ * @group legacy
+ *
+ * @internal
+ */
+class OffsetPageNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = new OffsetPageNormalizer();
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($original, $expected) {
+    $actual = $this->normalizer->denormalize($original, OffsetPage::class);
+    $this->assertEquals($expected['offset'], $actual->getOffset());
+    $this->assertEquals($expected['limit'], $actual->getSize());
+  }
+
+  /**
+   * Data provider for testGet.
+   */
+  public function denormalizeProvider() {
+    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 ::denormalize
+   */
+  public function testDenormalizeFail() {
+    $this->setExpectedException(BadRequestHttpException::class);
+    $this->normalizer->denormalize('lorem', OffsetPage::class);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/SortNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/SortNormalizerTest.php
new file mode 100644
index 0000000..41a4581
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/SortNormalizerTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\jsonapi\Normalizer\SortNormalizer;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Query\Sort;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\SortNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ * @group legacy
+ *
+ * @internal
+ */
+class SortNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = new SortNormalizer($this->getFieldResolver('foo', 'bar'));
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($input, $expected) {
+    $sort = $this->normalizer->denormalize($input, Sort::class, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']);
+    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 pamaters and their expected expansions.
+   */
+  public function denormalizeProvider() {
+    return [
+      ['lorem', [['path' => 'foo', 'direction' => 'ASC', 'langcode' => NULL]]],
+      ['-lorem', [['path' => 'foo', 'direction' => 'DESC', 'langcode' => NULL]]],
+      ['-lorem,ipsum', [
+        ['path' => 'foo', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'bar', 'direction' => 'ASC', 'langcode' => NULL],
+      ],
+      ],
+      ['-lorem,-ipsum', [
+        ['path' => 'foo', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'bar', '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' => 'foo', 'direction' => 'ASC', 'langcode' => NULL],
+        ['path' => 'bar', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'baz', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'qux', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeFailProvider
+   */
+  public function testDenormalizeFail($input) {
+    $this->setExpectedException(BadRequestHttpException::class);
+    $sort = $this->normalizer->denormalize($input, Sort::class);
+  }
+
+  /**
+   * Data provider for testDenormalizeFail.
+   */
+  public function denormalizeFailProvider() {
+    return [
+      [[['lorem']]],
+      [''],
+    ];
+  }
+
+  /**
+   * Provides a mock field resolver.
+   */
+  protected function getFieldResolver($entity_type_id, $bundle) {
+    $field_resolver = $this->prophesize(FieldResolver::class);
+    $field_resolver->resolveInternalEntityQueryPath('foo', 'bar', 'lorem')->willReturn('foo');
+    $field_resolver->resolveInternalEntityQueryPath('foo', 'bar', 'ipsum')->willReturn('bar');
+    $field_resolver->resolveInternalEntityQueryPath('foo', 'bar', 'dolor')->willReturn('baz');
+    $field_resolver->resolveInternalEntityQueryPath('foo', 'bar', 'sit')->willReturn('qux');
+    return $field_resolver->reveal();
+  }
+
+}
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 0000000..a73af9e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php
@@ -0,0 +1,339 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Query;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer;
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Drupal\jsonapi\Normalizer\FilterNormalizer;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\image\Kernel\ImageFieldCreationTrait;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+
+/**
+ * @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',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * 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->normalizer = new FilterNormalizer(
+      $this->container->get('jsonapi.field_resolver'),
+      new EntityConditionNormalizer(),
+      new EntityConditionGroupNormalizer()
+    );
+    $this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node');
+  }
+
+  /**
+   * @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`.');
+    $normalized = [
+      'colors' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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`.');
+    $normalized = [
+      'photo' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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`.');
+    $normalized = [
+      'photo.alt' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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`.');
+    $normalized = [
+      'uid' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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`.');
+    $normalized = [
+      'colors.foobar' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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).');
+    $normalized = [
+      'promote.value' => '',
+    ];
+    $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+      'entity_type_id' => 'node',
+      'bundle' => 'painting',
+    ]);
+  }
+
+  /**
+   * @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);
+    };
+
+    foreach ($data as $case) {
+      $normalized = $case[0];
+      $expected_query = $case[1];
+      // Denormalize the test filter into the object we want to test.
+      $filter = $this->normalizer->denormalize($normalized, Filter::class, NULL, [
+        'entity_type_id' => 'node',
+        'bundle' => 'painting',
+      ]);
+
+      $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();
+    }
+  }
+
+}
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 0000000..3ca39f8
--- /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 0000000..34da144
--- /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/Serializer/SerializerTest.php b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
new file mode 100644
index 0000000..7d44070
--- /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\FieldNormalizerValue;
+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_do_not_use_removal_imminent');
+    $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 FieldNormalizerValue);
+
+    $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 FieldNormalizerValue);
+
+    // 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 0000000..ad5babf
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Traits/CommonCollectionFilterAccessTestPatternsTrait.php
@@ -0,0 +1,171 @@
+<?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',
+      '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 0000000..e3dfba6
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php
@@ -0,0 +1,319 @@
+<?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\schemata\SchemaFactory;
+use Drupal\schemata\Encoder\JsonSchemaEncoder;
+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 ::onResponse
+   * @requires function Drupal\schemata\SchemaFactory::__construct
+   */
+  public function testValidateResponseSchemata() {
+    $request = $this->createRequest(
+      'jsonapi.node--article.individual',
+      new ResourceType('node', 'article', NULL)
+    );
+
+    $response = $this->createResponse('{"data":null}');
+
+    // The validator should be called *once* if schemata is *not* installed.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1);
+    $validator->isValid()->willReturn(TRUE);
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Run validations.
+    $this->subscriber->doValidateResponse($response, $request);
+
+    // The validator should be called *twice* if schemata is installed.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(2);
+    $validator->isValid()->willReturn(TRUE);
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Make the schemata factory available.
+    $schema_factory = $this->prophesize(SchemaFactory::class);
+    $schema_factory->create('node', 'article')->willReturn('{}');
+    $this->subscriber->setSchemaFactory($schema_factory->reveal());
+
+    // Run validations.
+    $this->subscriber->doValidateResponse($response, $request);
+
+    // The validator resource specific schema should *not* be validated on
+    // 'related' routes.
+    $request = $this->createRequest(
+      'jsonapi.node--article.related',
+      new ResourceType('node', 'article', NULL)
+    );
+
+    // Since only the generic schema should be validated, the validator should
+    // only be called once.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1);
+    $validator->isValid()->willReturn(TRUE);
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Run validations.
+    $this->subscriber->doValidateResponse($response, $request);
+
+    // The validator resource specific schema should *not* be validated on
+    // 'relationship' routes.
+    $request = $this->createRequest(
+      'jsonapi.node--article.relationship',
+      new ResourceType('node', 'article', NULL)
+    );
+
+    // Since only the generic schema should be validated, the validator should
+    // only be called once.
+    $validator = $this->prophesize(Validator::class);
+    $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1);
+    $validator->isValid()->willReturn(TRUE);
+    $this->subscriber->setValidator($validator->reveal());
+
+    // Run validations.
+    $this->subscriber->doValidateResponse($response, $request);
+  }
+
+  /**
+   * @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 0000000..a55c096
--- /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 0000000..2772dd0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
@@ -0,0 +1,216 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\LinkManager;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\Core\Utility\UnroutedUrlAssemblerInterface;
+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'), FALSE)
+      ->will(function ($args) {
+        return $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);
+    // Have the request return the desired page parameter.
+    $page_param = $this->prophesize(OffsetPage::class);
+    $page_param->getOffset()->willReturn($offset);
+    $page_param->getSize()->willReturn($size);
+    $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->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]);
+    $request->query = new ParameterBag(['amet' => 'pax']);
+
+    $context = ['has_next_page' => $has_next_page];
+    if ($include_count) {
+      $context['total_count'] = $total;
+    }
+
+    $links = $this->linkManager
+      ->getPagerLinks($request->reveal(), $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);
+    $assembler->assemble(Argument::type('string'), ['external' => TRUE, 'query' => ['dolor' => 'sid']], FALSE)
+      ->will(function ($args) {
+          return $args[0] . '?dolor=sid';
+      })
+      ->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']));
+
+    // 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()));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
new file mode 100644
index 0000000..79987c4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Normalizer\ConfigEntityNormalizer;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class ConfigEntityNormalizerTest extends UnitTestCase {
+
+  /**
+   * The normalizer under test.
+   *
+   * @var \Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    $link_manager = $this->prophesize(LinkManager::class);
+
+    $field_mapping = array_fill_keys([
+      'lorem',
+      'ipsum',
+      'dolor',
+      'sid',
+      'amet',
+      'ra',
+      'foo',
+    ], TRUE);
+    $resource_type = new ResourceType('dolor', 'sid', NULL, FALSE, TRUE, TRUE, $field_mapping);
+    $resource_type->setRelatableResourceTypes([]);
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository->get(Argument::type('string'), Argument::type('string'))
+      ->willReturn($resource_type);
+
+    $this->normalizer = new ConfigEntityNormalizer(
+      $link_manager->reveal(),
+      $resource_type_repository->reveal(),
+      $this->prophesize(EntityTypeManagerInterface::class)->reveal(),
+      $this->prophesize(EntityFieldManagerInterface::class)->reveal(),
+      $this->prophesize(FieldTypePluginManagerInterface::class)->reveal()
+    );
+  }
+
+  /**
+   * @covers ::normalize
+   * @dataProvider normalizeProvider
+   */
+  public function testNormalize($input, $expected) {
+    $entity = $this->prophesize(ConfigEntityInterface::class);
+    $entity->toArray()->willReturn(['amet' => $input]);
+    $entity->getCacheContexts()->willReturn([]);
+    $entity->getCacheTags()->willReturn([]);
+    $entity->getCacheMaxAge()->willReturn(-1);
+    $entity->getEntityTypeId()->willReturn('');
+    $entity->bundle()->willReturn('');
+    $normalized = $this->normalizer->normalize($entity->reveal(), 'api_json', []);
+    $first = $normalized->getValues();
+    $first = reset($first);
+    $this->assertSame($expected, $first->rasterizeValue());
+  }
+
+  /**
+   * Data provider for the normalize test.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function normalizeProvider() {
+    return [
+      ['lorem', 'lorem'],
+      [
+        ['ipsum' => 'dolor', 'ra' => 'foo'],
+        ['ipsum' => 'dolor', 'ra' => 'foo'],
+      ],
+      [
+        ['ipsum' => 'dolor'],
+        ['ipsum' => 'dolor'],
+      ],
+      [
+        ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]],
+        ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]],
+      ],
+    ];
+  }
+
+}
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 0000000..eefcb96
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
@@ -0,0 +1,55 @@
+<?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->rasterizeValue();
+    $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->assertNull($error['meta']['trace'][1]['args'][0]);
+
+    $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->rasterizeValue();
+    $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 0000000..20dcfde
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,258 @@
+<?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\jsonapi\LinkManager\LinkManager;
+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() {
+    $link_manager = $this->prophesize(LinkManager::class);
+    $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(
+      $link_manager->reveal(),
+      $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/RelationshipNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/RelationshipNormalizerTest.php
new file mode 100644
index 0000000..a60b034
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/RelationshipNormalizerTest.php
@@ -0,0 +1,203 @@
+<?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\RelationshipNormalizer;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\RelationshipNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RelationshipNormalizerTest 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],
+    ]);
+
+    $link_manager = $this->prophesize(LinkManager::class);
+    $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 RelationshipNormalizer(
+      $resource_type_repository->reveal(),
+      $link_manager->reveal(),
+      $field_manager->reveal(),
+      $entity_repository->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/Normalizer/Value/EntityNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
new file mode 100644
index 0000000..a3857f8
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValueInterface;
+use Drupal\node\NodeInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class EntityNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The EntityNormalizerValue object.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+   */
+  protected $object;
+
+  /**
+   * The cache contexts manager.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheContextsManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
+
+    $container = new ContainerBuilder();
+    $container->set('cache_contexts_manager', $this->cacheContextsManager);
+    \Drupal::setContainer($container);
+
+    $field1 = $this->prophesize(FieldNormalizerValueInterface::class);
+    $field1->getPropertyType()->willReturn('attributes');
+    $field1->rasterizeValue()->willReturn('dummy_title');
+    $field1->getCacheContexts()->willReturn(['ccbar']);
+    $field1->getCacheTags()->willReturn(['ctbar']);
+    $field1->getCacheMaxAge()->willReturn(20);
+    $field2 = $this->prophesize(RelationshipNormalizerValue::class);
+    $field2->getPropertyType()->willReturn('relationships');
+    $field2->rasterizeValue()->willReturn(['data' => ['type' => 'node', 'id' => 2]]);
+    $field2->getCacheContexts()->willReturn(['ccbaz']);
+    $field2->getCacheTags()->willReturn(['ctbaz']);
+    $field2->getCacheMaxAge()->willReturn(25);
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[0]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
+        'attributes' => ['body' => 'dummy_body1'],
+      ],
+    ]);
+    $included[0]->getCacheContexts()->willReturn(['lorem', 'ipsum']);
+    // Type & id duplicated on purpose.
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[1]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
+        'attributes' => ['body' => 'dummy_body2'],
+      ],
+    ]);
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[2]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5',
+        'attributes' => ['body' => 'dummy_body3'],
+      ],
+    ]);
+    $context = [
+      'resource_type' => new ResourceType('node', 'article',
+        NodeInterface::class),
+    ];
+    $entity = $this->prophesize(EntityInterface::class);
+    $entity->uuid()->willReturn('248150b2-79a2-4b44-9f49-bf405a51414a');
+    $entity->isNew()->willReturn(FALSE);
+    $entity->getEntityTypeId()->willReturn('node');
+    $entity->bundle()->willReturn('article');
+    $entity->getCacheContexts()->willReturn(['ccfoo']);
+    $entity->getCacheTags()->willReturn(['ctfoo']);
+    $entity->getCacheMaxAge()->willReturn(15);
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+
+    // Stub the addCacheableDependency on the SUT. We'll test the cacheable
+    // metadata bubbling using Kernel tests.
+    $this->object = $this->getMockBuilder(EntityNormalizerValue::class)
+      ->setMethods(['addCacheableDependency'])
+      ->setConstructorArgs([
+        ['title' => $field1->reveal(), 'field_related' => $field2->reveal()],
+        $context,
+        $entity->reveal(),
+        ['link_manager' => $link_manager->reveal()],
+      ])
+      ->getMock();
+    $this->object->method('addCacheableDependency');
+  }
+
+  /**
+   * @covers ::__construct
+   */
+  public function testCacheability() {
+    $this->assertSame(['ccbar', 'ccbaz', 'ccfoo'], $this->object->getCacheContexts());
+    $this->assertSame(['ctbar', 'ctbaz', 'ctfoo'], $this->object->getCacheTags());
+    $this->assertSame(15, $this->object->getCacheMaxAge());
+  }
+
+  /**
+   * @covers ::rasterizeValue
+   */
+  public function testRasterizeValue() {
+    $this->assertEquals([
+      'type' => 'node--article',
+      'id' => '248150b2-79a2-4b44-9f49-bf405a51414a',
+      'attributes' => ['title' => 'dummy_title'],
+      'relationships' => [
+        'field_related' => ['data' => ['type' => 'node', 'id' => 2]],
+      ],
+      'links' => [
+        'self' => ['href' => 'dummy_entity_link'],
+      ],
+    ], $this->object->rasterizeValue());
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php
new file mode 100644
index 0000000..17dcbfd
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class FieldItemNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $expected) {
+    $object = new FieldItemNormalizerValue($values, new CacheableMetadata());
+    $this->assertEquals($expected, $object->rasterizeValue());
+  }
+
+  /**
+   * Provider for testRasterizeValue.
+   */
+  public function rasterizeValueProvider() {
+    return [
+      [['value' => 1], 1],
+      [['value' => 1, 'safe_value' => 1], ['value' => 1, 'safe_value' => 1]],
+      [[], []],
+      [[NULL], NULL],
+      [
+        [
+          'lorem' => [
+            'ipsum' => [
+              'dolor' => 'sid',
+              'amet' => 'ra',
+            ],
+          ],
+        ],
+        ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php
new file mode 100644
index 0000000..5014c86
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class FieldNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The cache contexts manager.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheContextsManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
+
+    $container = new ContainerBuilder();
+    $container->set('cache_contexts_manager', $this->cacheContextsManager);
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @covers ::rasterizeValue
+   * @covers ::__construct
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $cardinality, $expected) {
+    $object = new FieldNormalizerValue(AccessResult::allowed()->cachePerUser()->addCacheTags(['field:foo']), $values, $cardinality, 'attributes');
+    $this->assertEquals($expected, $object->rasterizeValue());
+    $this->assertSame(['ccfoo', 'user'], $object->getCacheContexts());
+    $this->assertSame(['ctfoo', 'field:foo'], $object->getCacheTags());
+    $this->assertSame(15, $object->getCacheMaxAge());
+  }
+
+  /**
+   * Data provider for testRasterizeValue.
+   */
+  public function rasterizeValueProvider() {
+    $uuid_raw = '4ae99eec-8b0e-41f7-9400-fbd65c174902';
+    $uuid_value = $this->prophesize(FieldItemNormalizerValue::class);
+    $uuid_value->rasterizeValue()->willReturn('4ae99eec-8b0e-41f7-9400-fbd65c174902');
+    $uuid_value->getCacheContexts()->willReturn(['ccfoo']);
+    $uuid_value->getCacheTags()->willReturn(['ctfoo']);
+    $uuid_value->getCacheMaxAge()->willReturn(15);
+    return [
+      [[$uuid_value->reveal()], 1, $uuid_raw],
+      [
+        [
+          $uuid_value->reveal(),
+          $uuid_value->reveal(),
+        ],
+        -1,
+        [$uuid_raw, $uuid_raw],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php
new file mode 100644
index 0000000..542944a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RelationshipItemNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $entity_type_id, $bundle, $expected) {
+    $object = new RelationshipItemNormalizerValue($values, new CacheableMetadata(), new ResourceType($entity_type_id, $bundle, NULL));
+    $this->assertEquals($expected, $object->rasterizeValue());
+  }
+
+  /**
+   * Data provider for testRasterizeValue.
+   */
+  public function rasterizeValueProvider() {
+    return [
+      [
+        ['target_id' => 1],
+        'node',
+        'article',
+        ['type' => 'node--article', 'id' => 1],
+      ],
+      [
+        ['value' => 1],
+        'node',
+        'page',
+        ['type' => 'node--page', 'id' => 1],
+      ],
+      [[1], 'node', 'foo', ['type' => 'node--foo', 'id' => 1]],
+      [[], 'node', 'bar', []],
+      [[NULL], 'node', 'baz', NULL],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php
new file mode 100644
index 0000000..88c4159
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php
@@ -0,0 +1,322 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RelationshipNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The cache contexts manager.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheContextsManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
+
+    $container = new ContainerBuilder();
+    $container->set('cache_contexts_manager', $this->cacheContextsManager);
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $cardinality, $expected, CacheableMetadata $expected_cacheability) {
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $resource_type = new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL);
+    $resource_type->setRelatableResourceTypes([
+      'ipsum' => [$resource_type],
+    ]);
+    $object = new RelationshipNormalizerValue(AccessResult::allowed()->cachePerUser()->addCacheTags(['relationship:foo']), $values, $cardinality, [
+      'link_manager' => $link_manager->reveal(),
+      'host_entity_id' => 'lorem',
+      'resource_type' => $resource_type,
+      'field_name' => 'ipsum',
+    ]);
+    $this->assertEquals($expected, $object->rasterizeValue());
+    $this->assertSame($expected_cacheability->getCacheContexts(), $object->getCacheContexts());
+    $this->assertSame($expected_cacheability->getCacheTags(), $object->getCacheTags());
+    $this->assertSame($expected_cacheability->getCacheMaxAge(), $object->getCacheMaxAge());
+  }
+
+  /**
+   * Data provider fortestRasterizeValue.
+   */
+  public function rasterizeValueProvider() {
+    $uid_raw = 1;
+    $uid1 = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $uid1->rasterizeValue()->willReturn(['type' => 'user', 'id' => $uid_raw++]);
+    $uid1->getCacheContexts()->willReturn(['ccfoo']);
+    $uid1->getCacheTags()->willReturn(['ctfoo']);
+    $uid1->getCacheMaxAge()->willReturn(15);
+    $uid2 = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $uid2->rasterizeValue()->willReturn(['type' => 'user', 'id' => $uid_raw]);
+    $uid2->getCacheContexts()->willReturn(['ccbar']);
+    $uid2->getCacheTags()->willReturn(['ctbar']);
+    $uid2->getCacheMaxAge()->willReturn(10);
+    $img_id = $this->randomMachineName();
+    $img1 = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $img1->rasterizeValue()->willReturn([
+      'type' => 'file--file',
+      'id' => $img_id,
+      'meta' => ['alt' => 'Cute llama', 'title' => 'My spirit animal'],
+    ]);
+    $img1->getCacheContexts()->willReturn(['ccimg1']);
+    $img1->getCacheTags()->willReturn(['ctimg1']);
+    $img1->getCacheMaxAge()->willReturn(100);
+    $img2 = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $img2->rasterizeValue()->willReturn([
+      'type' => 'file--file',
+      'id' => $img_id,
+      'meta' => ['alt' => 'Adorable llama', 'title' => 'My spirit animal 😍'],
+    ]);
+    $img2->getCacheContexts()->willReturn(['ccimg2']);
+    $img2->getCacheTags()->willReturn(['ctimg2']);
+    $img2->getCacheMaxAge()->willReturn(50);
+
+    $links = [
+      'self' => ['href' => 'dummy_entity_link'],
+      'related' => ['href' => 'dummy_entity_link'],
+    ];
+    return [
+      'single cardinality' => [[$uid1->reveal()], 1, [
+        'data' => ['type' => 'user', 'id' => 1],
+        'links' => $links,
+      ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccfoo', 'user'])
+          ->setCacheTags(['ctfoo', 'relationship:foo'])
+          ->setCacheMaxAge(15),
+      ],
+      'multiple cardinality' => [
+        [$uid1->reveal(), $uid2->reveal()], 2, [
+          'data' => [
+            ['type' => 'user', 'id' => 1],
+            ['type' => 'user', 'id' => 2],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccbar', 'ccfoo', 'user'])
+          ->setCacheTags(['ctbar', 'ctfoo', 'relationship:foo'])
+          ->setCacheMaxAge(10),
+      ],
+      'multiple cardinality, all same values' => [
+        [$uid1->reveal(), $uid1->reveal()], 2, [
+          'data' => [
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 0]],
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 1]],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccfoo', 'user'])
+          ->setCacheTags(['ctfoo', 'relationship:foo'])
+          ->setCacheMaxAge(15),
+      ],
+      'multiple cardinality, some same values' => [
+        [$uid1->reveal(), $uid2->reveal(), $uid1->reveal()], 2, [
+          'data' => [
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 0]],
+            ['type' => 'user', 'id' => 2],
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 1]],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccbar', 'ccfoo', 'user'])
+          ->setCacheTags(['ctbar', 'ctfoo', 'relationship:foo'])
+          ->setCacheMaxAge(10),
+      ],
+      'single cardinality, with meta' => [[$img1->reveal()], 1, [
+        'data' => [
+          'type' => 'file--file',
+          'id' => $img_id,
+          'meta' => [
+            'alt' => 'Cute llama',
+            'title' => 'My spirit animal',
+          ],
+        ],
+        'links' => $links,
+      ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccimg1', 'user'])
+          ->setCacheTags(['ctimg1', 'relationship:foo'])
+          ->setCacheMaxAge(100),
+      ],
+      'multiple cardinality, all same values, with meta' => [
+        [$img1->reveal(), $img1->reveal()], 2, [
+          'data' => [
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 0,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 1,
+              ],
+            ],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccimg1', 'user'])
+          ->setCacheTags(['ctimg1', 'relationship:foo'])
+          ->setCacheMaxAge(100),
+      ],
+      'multiple cardinality, some same values with same values but different meta' => [
+        [$img1->reveal(), $img1->reveal(), $img2->reveal()], 2, [
+          'data' => [
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 0,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 1,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Adorable llama',
+                'title' => 'My spirit animal 😍',
+                'arity' => 2,
+              ],
+            ],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccimg1', 'ccimg2', 'user'])
+          ->setCacheTags(['ctimg1', 'ctimg2', 'relationship:foo'])
+          ->setCacheMaxAge(50),
+      ],
+      'all the edge cases!' => [
+        [
+          $img1->reveal(),
+          $img1->reveal(),
+          $img2->reveal(),
+          $uid1->reveal(),
+          $uid2->reveal(),
+          $uid1->reveal(),
+        ],
+        10,
+        [
+          'data' => [
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 0,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Cute llama',
+                'title' => 'My spirit animal',
+                'arity' => 1,
+              ],
+            ],
+            [
+              'type' => 'file--file',
+              'id' => $img_id,
+              'meta' => [
+                'alt' => 'Adorable llama',
+                'title' => 'My spirit animal 😍',
+                'arity' => 2,
+              ],
+            ],
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 0]],
+            ['type' => 'user', 'id' => 2],
+            ['type' => 'user', 'id' => 1, 'meta' => ['arity' => 1]],
+          ],
+          'links' => $links,
+        ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccbar', 'ccfoo', 'ccimg1', 'ccimg2', 'user'])
+          ->setCacheTags([
+            'ctbar',
+            'ctfoo',
+            'ctimg1',
+            'ctimg2',
+            'relationship:foo',
+          ])
+          ->setCacheMaxAge(10),
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::rasterizeValue
+   */
+  public function testRasterizeValueFails() {
+    $uid1 = $this->prophesize(FieldItemNormalizerValue::class);
+    $uid1->rasterizeValue()->willReturn(1);
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $this->setExpectedException(\RuntimeException::class, 'Unexpected normalizer item value for this Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue.');
+    new RelationshipNormalizerValue(AccessResult::allowed(), [$uid1->reveal()], 1, [
+      'link_manager' => $link_manager->reveal(),
+      'host_entity_id' => 'lorem',
+      'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL),
+      'field_name' => 'ipsum',
+    ]);
+  }
+
+}
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 0000000..0994dac
--- /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\Normalizer\Relationship;
+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(Relationship::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'],
+    ];
+  }
+
+}
