diff --git a/core/composer.json b/core/composer.json
index 62c26c4..0efb642 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -126,6 +126,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..fb2ed41
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.api.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @file
+ * Documentation related to JSON API.
+ */
+
+/**
+ * @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 "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.
+ *
+ * The JSON API module does provide a PHP API to generate a JSON API
+ * representation of entities:
+ *
+ * @code
+ * \Drupal::service('jsonapi.entity.to_jsonapi')->serialize($entity)
+ * @endcode
+ *
+ *
+ * @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. 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 he 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.
+ *
+ * @}
+ */
diff --git a/core/modules/jsonapi/jsonapi.info.yml b/core/modules/jsonapi/jsonapi.info.yml
new file mode 100644
index 0000000..946d44d
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.info.yml
@@ -0,0 +1,10 @@
+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:system (>=8.3)
+  - 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..997d981
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.module
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @file
+ * Module implementation file.
+ */
+
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+
+/**
+ * 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_base_field_info().
+ *
+ * @todo This should probably live in core, but for now we will keep it as a
+ * temporary solution. There are similar unresolved efforts already happening
+ * there.
+ *
+ * @see https://www.drupal.org/node/2825487
+ */
+function jsonapi_entity_base_field_info(EntityTypeInterface $entity_type) {
+  $fields = [];
+  if ($entity_type->id() == 'file') {
+    $fields['url'] = BaseFieldDefinition::create('string')
+      ->setLabel(t('Download URL'))
+      ->setDescription(t('The download URL of the file.'))
+      ->setComputed(TRUE)
+      ->setCustomStorage(TRUE)
+      ->setClass('\Drupal\jsonapi\Field\FileDownloadUrl')
+      ->setDisplayOptions('view', [
+        'label' => 'above',
+        'weight' => -5,
+        'region' => 'hidden',
+      ])
+      ->setDisplayConfigurable('view', TRUE);
+  }
+  return $fields;
+}
diff --git a/core/modules/jsonapi/jsonapi.permissions.yml b/core/modules/jsonapi/jsonapi.permissions.yml
new file mode 100644
index 0000000..f5e5908
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.permissions.yml
@@ -0,0 +1,3 @@
+access jsonapi resource list:
+  description: 'Gives access to the list of resources served by the JSON API module.'
+  title: 'Access JSON API resource list'
diff --git a/core/modules/jsonapi/jsonapi.routing.yml b/core/modules/jsonapi/jsonapi.routing.yml
new file mode 100644
index 0000000..f4d060c
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.routing.yml
@@ -0,0 +1,3 @@
+route_callbacks:
+  - '\Drupal\jsonapi\Routing\Routes::entryPoint'
+  - '\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..b73d2a0
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.services.yml
@@ -0,0 +1,167 @@
+services:
+  jsonapi.serializer_do_not_use_removal_imminent:
+    class: Drupal\jsonapi\Serializer\Serializer
+    public: false
+    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', '@serializer.normalizer.jsonapi_document_toplevel.jsonapi',]
+    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']
+    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']
+    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']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.jsonapi_document_toplevel.jsonapi:
+    class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.current_context', '@entity_type.manager', '@jsonapi.resource_type.repository', '@jsonapi.field_resolver']
+    tags:
+      - { name: jsonapi_normalizer_do_not_use_removal_imminent }
+  serializer.normalizer.entity_reference_field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+    arguments: ['@jsonapi.link_manager', '@entity_field.manager', '@plugin.manager.field.field_type', '@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']
+  jsonapi.route_enhancer:
+    class: Drupal\jsonapi\Routing\RouteEnhancer
+    tags:
+      - { name: route_enhancer }
+  jsonapi.params.enhancer:
+    class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
+    arguments: ['@serializer.normalizer.filter.jsonapi', '@serializer.normalizer.sort.jsonapi', '@serializer.normalizer.offset_page.jsonapi']
+    tags:
+      - { name: route_enhancer }
+  jsonapi.link_manager:
+    class: Drupal\jsonapi\LinkManager\LinkManager
+    arguments: ['@router.no_access_checks', '@url_generator']
+  jsonapi.current_context:
+    class: Drupal\jsonapi\Context\CurrentContext
+    arguments: ['@jsonapi.resource_type.repository', '@request_stack', '@current_route_match']
+  jsonapi.field_resolver:
+    class: Drupal\jsonapi\Context\FieldResolver
+    arguments: ['@jsonapi.current_context', '@entity_type.manager', '@entity_field.manager', '@entity_type.bundle.info', '@jsonapi.resource_type.repository']
+  access_check.jsonapi.custom_query_parameter_names:
+    class: Drupal\jsonapi\Access\CustomQueryParameterNamesAccessCheck
+    tags:
+      - { name: access_check, applies_to: _jsonapi_custom_query_parameter_names, needs_incoming_request: TRUE }
+  paramconverter.jsonapi.entity_uuid:
+    class: Drupal\jsonapi\ParamConverter\EntityUuidConverter
+    tags:
+      # Priority 10, to ensure it runs before @paramconverter.entity.
+      - { name: paramconverter, priority: 10 }
+    arguments: ['@entity.manager']
+  jsonapi.exception_subscriber:
+    class: Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '%serializer.formats%']
+  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 }
+
+  jsonapi.entity.to_jsonapi:
+    class: Drupal\jsonapi\EntityToJsonApi
+    arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '@jsonapi.resource_type.repository', '@current_user', '@request_stack']
+
+  logger.channel.jsonapi:
+    parent: logger.channel_base
+    arguments: ['jsonapi']
+
+  # Controllers.
+  jsonapi.request_handler:
+    class: \Drupal\jsonapi\Controller\RequestHandler
+    arguments:
+      - '@jsonapi.serializer_do_not_use_removal_imminent'
+      - '@jsonapi.current_context'
+      - '@renderer'
+      - '@jsonapi.resource_type.repository'
+      - '@entity_type.manager'
+      - '@entity_field.manager'
+      - '@plugin.manager.field.field_type'
+      - '@jsonapi.link_manager'
+
+  # Event subscribers.
+  jsonapi.resource_response.subscriber:
+    class: Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber
+    arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '@renderer', '@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 }
+
+  # 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.
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/CustomQueryParameterNamesAccessCheck.php b/core/modules/jsonapi/src/Access/CustomQueryParameterNamesAccessCheck.php
new file mode 100644
index 0000000..923be83
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/CustomQueryParameterNamesAccessCheck.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\jsonapi\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\jsonapi\JsonApiSpec;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Validates custom (implementation-specific) query parameter names.
+ *
+ * @see http://jsonapi.org/format/#query-parameters
+ *
+ * @internal
+ */
+class CustomQueryParameterNamesAccessCheck implements AccessInterface {
+
+  /**
+   * Denies access when using invalid custom JSON API query parameter names.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The access result.
+   */
+  public function access(Request $request) {
+    $json_api_params = $request->attributes->get('_json_api_params', []);
+    if (!$this->validate($json_api_params)) {
+      return AccessResult::forbidden();
+    }
+    return AccessResult::allowed();
+  }
+
+  /**
+   * Validates custom JSON API query parameters.
+   *
+   * @param string[] $json_api_params
+   *   The JSON API parameters.
+   *
+   * @return bool
+   *   Whether the parameter is valid.
+   */
+  protected function validate(array $json_api_params) {
+    foreach (array_keys($json_api_params) as $query_parameter_name) {
+      // Ignore reserved (official) query parameters.
+      if (in_array($query_parameter_name, JsonApiSpec::getReservedQueryParameters())) {
+        continue;
+      }
+
+      if (!JsonApiSpec::isValidCustomQueryParameter($query_parameter_name)) {
+        return FALSE;
+      }
+    }
+
+    return TRUE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Context/CurrentContext.php b/core/modules/jsonapi/src/Context/CurrentContext.php
new file mode 100644
index 0000000..a75410d
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/CurrentContext.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Drupal\jsonapi\Context;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Service for accessing information about the current JSON API request.
+ *
+ * @internal
+ */
+class CurrentContext {
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The current JSON API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The current request.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface
+   */
+  protected $routeMatch;
+
+  /**
+   * Creates a CurrentContext object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The resource type repository.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The current route match.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RequestStack $request_stack, RouteMatchInterface $route_match) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->requestStack = $request_stack;
+    $this->routeMatch = $route_match;
+  }
+
+  /**
+   * Gets the JSON API resource type for the current request.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   The JSON API resource type for the current request.
+   */
+  public function getResourceType() {
+    if (!isset($this->resourceType)) {
+      $route = $this->routeMatch->getRouteObject();
+      $entity_type_id = $route->getRequirement('_entity_type');
+      $bundle = $route->getRequirement('_bundle');
+      $this->resourceType = $this->resourceTypeRepository
+        ->get($entity_type_id, $bundle);
+    }
+
+    return $this->resourceType;
+  }
+
+  /**
+   * Checks if the request is on a relationship.
+   *
+   * @return bool
+   *   TRUE if the request is on a relationship. FALSE otherwise.
+   */
+  public function isOnRelationship() {
+    return (bool) $this->routeMatch
+      ->getRouteObject()
+      ->getDefault('_on_relationship');
+  }
+
+  /**
+   * Get a value by key from the _json_api_params route parameter.
+   *
+   * @param string $parameter_key
+   *   The key by which to retrieve a route parameter.
+   *
+   * @return mixed
+   *   The JSON API provided parameter.
+   */
+  public function getJsonApiParameter($parameter_key) {
+    $params = $this
+      ->requestStack
+      ->getCurrentRequest()
+      ->attributes
+      ->get('_json_api_params');
+
+    return isset($params[$parameter_key]) ? $params[$parameter_key] : NULL;
+  }
+
+  /**
+   * Determines, whether the JSONAPI extension was requested.
+   *
+   * @todo Find a better place for such a JSONAPI derived information.
+   *
+   * @param string $extension_name
+   *   The extension name.
+   *
+   * @return bool
+   *   Returns TRUE, if the extension has been found.
+   */
+  public function hasExtension($extension_name) {
+    return in_array($extension_name, $this->getExtensions());
+  }
+
+  /**
+   * Returns a list of requested extensions.
+   *
+   * @return string[]
+   *   The extension names.
+   */
+  public function getExtensions() {
+    $content_type_header = $this
+      ->requestStack
+      ->getCurrentRequest()
+      ->headers
+      ->get('Content-Type');
+    if (preg_match('/ext="([^"]+)"/i', $content_type_header, $match)) {
+      $extensions = array_map('trim', explode(',', $match[1]));
+      return $extensions;
+    }
+    return [];
+  }
+
+  /**
+   * Reset the internal caches.
+   */
+  public function reset() {
+    unset($this->resourceType);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php
new file mode 100644
index 0000000..8e55301
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/FieldResolver.php
@@ -0,0 +1,350 @@
+<?php
+
+namespace Drupal\jsonapi\Context;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\TypedData\DataReferenceTargetDefinition;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Service which resolves public field names to and from Drupal field names.
+ *
+ * @internal
+ */
+class FieldResolver {
+
+  /**
+   * The entity type id.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * 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;
+
+  /**
+   * Creates a FieldResolver instance.
+   *
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The JSON API CurrentContext service.
+   * @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.
+   */
+  public function __construct(CurrentContext $current_context, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->currentContext = $current_context;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->entityTypeBundleInfo = $entity_type_bundle_info;
+    $this->resourceTypeRepository = $resource_type_repository;
+  }
+
+  /**
+   * Maps a Drupal field name to a public field name.
+   *
+   * Example:
+   *   'field_author.entity.field_first_name' -> 'author.firstName'.
+   *
+   * @param string $internal_field_name
+   *   The Drupal field name to map to a public field name.
+   *
+   * @return string
+   *   The mapped field name.
+   */
+  public function resolveExternal($internal_field_name) {
+    $resource_type = $this->currentContext->getResourceType();
+    return $resource_type->getPublicName($internal_field_name);
+  }
+
+  /**
+   * Resolves external field expressions into internal field expressions.
+   *
+   * 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 \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   */
+  public function resolveInternal($entity_type_id, $bundle, $external_field_name) {
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
+    if (empty($external_field_name)) {
+      throw new BadRequestHttpException('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);
+    $reference_breadcrumbs = [];
+    /* @var \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types */
+    $resource_types = [$resource_type];
+    while ($part = array_shift($parts)) {
+      $field_name = $this->getInternalName($part, $resource_types);
+
+      // If none of the resource types are traversable, assume that the
+      // remaining path parts are for sub-properties.
+      if (!$this->resourceTypesAreTraversable($resource_types)) {
+        $reference_breadcrumbs[] = $field_name;
+        return $this->constructInternalPath($reference_breadcrumbs, $parts);
+      }
+
+      $candidate_definitions = $this->getFieldItemDefinitions(
+        $resource_types,
+        $field_name
+      );
+
+      // If there are no definitions, then the field does not exist.
+      if (empty($candidate_definitions)) {
+        throw new BadRequestHttpException(sprintf(
+          'Invalid nested filtering. The field `%s`, given in the path `%s`, does not exist.',
+          $part,
+          $external_field_name
+        ));
+      }
+
+      // Get all of the referenceable resource types.
+      $resource_types = $this->getReferenceableResourceTypes($candidate_definitions);
+
+      // Keep a trail of entity reference field names.
+      $reference_breadcrumbs[] = $field_name;
+
+      // $field_name may not be a reference field. In that case we should treat
+      // the rest of the parts as sub-properties of the field.
+      if (empty($resource_types)) {
+        return $this->constructInternalPath($reference_breadcrumbs, $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('.entity.', $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, $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[] = $definitions[$field_name]->getItemDefinition();
+      }
+      return $result;
+    }, []);
+  }
+
+  /**
+   * 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 $resource_type->getInternalName($field_name);
+    }, $field_name);
+  }
+
+  /**
+   * 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\FieldItemDataDefinition $item_definition
+   *   The reference definition.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The list of resource types.
+   *
+   * @todo Add PHP type hint, see
+   *   https://www.drupal.org/project/jsonapi/issues/2933895
+   */
+  protected function collectResourceTypesForReference(FieldItemDataDefinition $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));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php
new file mode 100644
index 0000000..8b74090
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -0,0 +1,983 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Access\AccessibleInterface;
+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\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * Process all entity requests.
+ *
+ * @see \Drupal\jsonapi\Controller\RequestHandler
+ * @internal
+ */
+class EntityResource {
+
+  /**
+   * The JSON API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The current context service.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * The current context service.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * The link manager service.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * Instantiates a EntityResource object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON API resource type.
+   * @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\Context\CurrentContext $current_context
+   *   The current context.
+   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
+   *   The plugin manager for fields.
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager service.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The link manager service.
+   */
+  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager, LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
+    $this->resourceType = $resource_type;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->currentContext = $current_context;
+    $this->pluginManager = $plugin_manager;
+    $this->linkManager = $link_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+  }
+
+  /**
+   * Gets the individual entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The loaded entity.
+   * @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 getIndividual(EntityInterface $entity, Request $request, $response_code = 200) {
+    $entity_access = $entity->access('view', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to GET the selected resource.');
+    }
+    $response = $this->buildWrappedResponse($entity, $response_code);
+    $response->addCacheableDependency($entity_access);
+    return $response;
+  }
+
+  /**
+   * Verifies that the whole entity does not violate any validation constraints.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object.
+   *
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   *   If validation errors are found.
+   *
+   * @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
+   */
+  protected function validate(EntityInterface $entity) {
+    if (!$entity instanceof FieldableEntityInterface) {
+      return;
+    }
+
+    $violations = $entity->validate();
+
+    // Remove violations of inaccessible fields as they cannot stem from our
+    // changes.
+    $violations->filterByFieldAccess();
+
+    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\Core\Entity\EntityInterface $entity
+   *   The loaded entity.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  public function createIndividual(EntityInterface $entity, Request $request) {
+    $entity_access = $entity->access('create', NULL, TRUE);
+
+    if (!$entity_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException(NULL, $entity_access, '/data', 'The current user is not allowed to POST the selected resource.');
+    }
+
+    // Only check 'edit' permissions for fields that were actually submitted by
+    // the user. Field access makes no difference 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 = $this->resourceType->getInternalName($field_name);
+        $field_access = $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 = $this->resourceType->getInternalName($field_name);
+        $field_access = $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($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($entity)) {
+      throw new ConflictHttpException('Conflict: Entity already exists.');
+    }
+
+    $entity->save();
+
+    // Build response object.
+    $response = $this->buildWrappedResponse($entity, 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(
+      $entity->uuid(),
+      $this->resourceType,
+      [],
+      'individual'
+    );
+    $response->headers->set('Location', $entity_url);
+
+    // Return response object with updated headers info.
+    return $response;
+  }
+
+  /**
+   * Patches an individual entity.
+   *
+   * @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 \Drupal\Core\Entity\EntityStorageException
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   */
+  public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) {
+    $entity_access = $entity->access('update', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to PATCH the selected resource.');
+    }
+    $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 ($parsed_entity) {
+      $this->updateEntityField($parsed_entity, $destination, $field_name);
+      return $destination;
+    }, $entity);
+
+    $this->validate($entity);
+    $entity->save();
+    return $this->buildWrappedResponse($entity);
+  }
+
+  /**
+   * Deletes an 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\Core\Entity\EntityStorageException
+   * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   */
+  public function deleteIndividual(EntityInterface $entity, Request $request) {
+    $entity_access = $entity->access('delete', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to DELETE the selected resource.');
+    }
+    $entity->delete();
+    return new ResourceResponse(NULL, 204);
+  }
+
+  /**
+   * Gets the collection of entities.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  public function getCollection(Request $request) {
+    // Instantiate the query for the filtering.
+    $entity_type_id = $this->resourceType->getEntityTypeId();
+
+    $route_params = $request->attributes->get('_route_params');
+    $params = isset($route_params['_json_api_params']) ? $route_params['_json_api_params'] : [];
+    $query = $this->getCollectionQuery($entity_type_id, $params);
+
+    $results = $query->execute();
+
+    $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(array_column($collection_data, 'entity'));
+    $entity_collection->setHasNextPage($has_next_page);
+
+    // Calculate all the results and pass them to the EntityCollectionInterface.
+    if ($this->resourceType->includeCount()) {
+      $total_results = $this
+        ->getCollectionCountQuery($entity_type_id, $params)
+        ->execute();
+
+      $entity_collection->setTotalCount($total_results);
+    }
+
+    $response = $this->respondWithCollection($entity_collection, $entity_type_id);
+
+    // Add cacheable metadata for the access result.
+    $access_info = array_column($collection_data, 'access');
+    array_walk($access_info, function ($access) use ($response) {
+      $response->addCacheableDependency($access);
+    });
+
+    return $response;
+  }
+
+  /**
+   * Gets the related resource.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related_field
+   *   The related field name.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   */
+  public function getRelated(EntityInterface $entity, $related_field, Request $request) {
+    $related_field = $this->resourceType->getInternalName($related_field);
+    $this->relationshipAccess($entity, 'view', $related_field);
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->get($related_field);
+    $this->validateReferencedResource($field_list, $related_field);
+    // Add the cacheable metadata from the host entity.
+    $cacheable_metadata = CacheableMetadata::createFromObject($entity);
+    $is_multiple = $field_list
+      ->getDataDefinition()
+      ->getFieldStorageDefinition()
+      ->isMultiple();
+    if (!$is_multiple && $field_list->entity) {
+      $response = $this->getIndividual($field_list->entity, $request);
+      // Add cacheable metadata for host entity to individual response.
+      $response->addCacheableDependency($cacheable_metadata);
+      return $response;
+    }
+    $collection_data = [];
+    // 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()
+        );
+      }
+    );
+    foreach ($referenced_entities as $referenced_entity) {
+      $collection_data[$referenced_entity->id()] = static::getEntityAndAccess($referenced_entity);
+      $cacheable_metadata->addCacheableDependency($referenced_entity);
+    }
+    $entity_collection = new EntityCollection(array_column($collection_data, 'entity'));
+    $response = $this->buildWrappedResponse($entity_collection);
+
+    $access_info = array_column($collection_data, 'access');
+    array_walk($access_info, function ($access) use ($response) {
+      $response->addCacheableDependency($access);
+    });
+    // $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($cacheable_metadata);
+
+    return $response;
+  }
+
+  /**
+   * Gets the relationship of an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related_field
+   *   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(EntityInterface $entity, $related_field, Request $request, $response_code = 200) {
+    $related_field = $this->resourceType->getInternalName($related_field);
+    $this->relationshipAccess($entity, 'view', $related_field);
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field_list */
+    $field_list = $entity->get($related_field);
+    $this->validateReferencedResource($field_list, $related_field);
+    $response = $this->buildWrappedResponse($field_list, $response_code);
+    return $response;
+  }
+
+  /**
+   * Validates that the referenced field points to an enabled resource.
+   *
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface|null $field_list
+   *   The field list with the reference.
+   * @param string $related_field
+   *   The internal name of the related field.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   *   If the field is not a reference or the target resource is disabled.
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   If the $field_list is of the incorrect type.
+   */
+  protected function validateReferencedResource($field_list, $related_field) {
+    if (
+      !is_null($field_list) &&
+      !$field_list instanceof EntityReferenceFieldItemListInterface
+    ) {
+      throw new HttpException(500, 'Invalid internal structure for relationship field list.');
+    }
+    if (!$field_list || !$this->isRelationshipField($field_list)) {
+      throw new NotFoundHttpException(sprintf('The relationship %s is not present in this resource.', $related_field));
+    }
+  }
+
+  /**
+   * Adds a relationship to a to-many relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related_field
+   *   The related field name.
+   * @param mixed $parsed_field_list
+   *   The entity reference field list of items to add, or a response object in
+   *   case of error.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   */
+  public function createRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) {
+    $related_field = $this->resourceType->getInternalName($related_field);
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */
+    $this->relationshipAccess($entity, 'update', $related_field);
+    if ($parsed_field_list instanceof Response) {
+      // This usually means that there was an error, so there is no point on
+      // processing further.
+      return $parsed_field_list;
+    }
+    // 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_field};
+    $is_multiple = $field_list->getFieldDefinition()
+      ->getFieldStorageDefinition()
+      ->isMultiple();
+    if (!$is_multiple) {
+      throw new ConflictHttpException(sprintf('You can only POST to to-many relationships. %s is a to-one relationship.', $related_field));
+    }
+
+    $field_access = $field_list->access('edit', NULL, TRUE);
+    if (!$field_access->isAllowed()) {
+      $field_name = $field_list->getName();
+      throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name));
+    }
+    // Time to save the relationship.
+    foreach ($parsed_field_list as $field_item) {
+      $field_list->appendItem($field_item->getValue());
+    }
+    $this->validate($entity);
+    $entity->save();
+    return $this->getRelationship($entity, $related_field, $request, 201);
+  }
+
+  /**
+   * Updates the relationship of an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related_field
+   *   The related field name.
+   * @param mixed $parsed_field_list
+   *   The entity reference field list of items to add, or a response object in
+   *   case of error.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  public function patchRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) {
+    $related_field = $this->resourceType->getInternalName($related_field);
+    if ($parsed_field_list instanceof Response) {
+      // This usually means that there was an error, so there is no point on
+      // processing further.
+      return $parsed_field_list;
+    }
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */
+    $this->relationshipAccess($entity, 'update', $related_field);
+    // 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};
+    $is_multiple = $field_list->getFieldDefinition()
+      ->getFieldStorageDefinition()
+      ->isMultiple();
+    $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship';
+    $this->{$method}($entity, $parsed_field_list);
+    $this->validate($entity);
+    $entity->save();
+    return $this->getRelationship($entity, $related_field, $request);
+  }
+
+  /**
+   * Update a to-one relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list
+   *   The entity reference field list of items to add, or a response object in
+   *   case of error.
+   */
+  protected function doPatchIndividualRelationship(EntityInterface $entity, EntityReferenceFieldItemListInterface $parsed_field_list) {
+    if ($parsed_field_list->count() > 1) {
+      throw new BadRequestHttpException(sprintf('Provide a single relationship so to-one relationship fields (%s).', $parsed_field_list->getName()));
+    }
+    $this->doPatchMultipleRelationship($entity, $parsed_field_list);
+  }
+
+  /**
+   * Update a to-many relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list
+   *   The entity reference field list of items to add, or a response object in
+   *   case of error.
+   */
+  protected function doPatchMultipleRelationship(EntityInterface $entity, EntityReferenceFieldItemListInterface $parsed_field_list) {
+    $field_name = $parsed_field_list->getName();
+    $field_access = $parsed_field_list->access('edit', NULL, TRUE);
+    if (!$field_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name));
+    }
+    $entity->{$field_name} = $parsed_field_list;
+  }
+
+  /**
+   * Deletes the relationship of an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The requested entity.
+   * @param string $related_field
+   *   The related field name.
+   * @param mixed $parsed_field_list
+   *   The entity reference field list of items to add, or a response object in
+   *   case of error.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  public function deleteRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request = NULL) {
+    if ($parsed_field_list instanceof Response) {
+      // This usually means that there was an error, so there is no point on
+      // processing further.
+      return $parsed_field_list;
+    }
+    if ($parsed_field_list 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_field));
+    }
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */
+    $this->relationshipAccess($entity, 'update', $related_field);
+
+    $field_name = $parsed_field_list->getName();
+    $field_access = $parsed_field_list->access('edit', NULL, TRUE);
+    if (!$field_access->isAllowed()) {
+      throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name));
+    }
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
+    $field_list = $entity->{$related_field};
+    $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_field));
+    }
+
+    // Compute the list of current values and remove the ones in the payload.
+    $current_values = $field_list->getValue();
+    $deleted_values = $parsed_field_list->getValue();
+    $keep_values = array_udiff($current_values, $deleted_values, function ($first, $second) {
+      return reset($first) - reset($second);
+    });
+    // Replace the existing field with one containing the relationships to keep.
+    $entity->{$related_field} = $this->pluginManager
+      ->createFieldItemList($entity, $related_field, $keep_values);
+
+    // Save the entity and return the response object.
+    $this->validate($entity);
+    $entity->save();
+    return $this->getRelationship($entity, $related_field, $request, 201);
+  }
+
+  /**
+   * Gets a basic query for a collection.
+   *
+   * @param string $entity_type_id
+   *   The entity type for the entity query.
+   * @param array $params
+   *   The parameters for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  protected function getCollectionQuery($entity_type_id, array $params) {
+    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+    $entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
+
+    $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));
+    }
+
+    // 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 = $this->resourceType->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 string $entity_type_id
+   *   The entity type for the entity query.
+   * @param array $params
+   *   The parameters for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  protected function getCollectionCountQuery($entity_type_id, array $params) {
+    // Reset the range to get all the available results.
+    return $this->getCollectionQuery($entity_type_id, $params)->range()->count();
+  }
+
+  /**
+   * Builds a response with the appropriate wrapped document.
+   *
+   * @param mixed $data
+   *   The data to wrap.
+   * @param int $response_code
+   *   The response code.
+   * @param array $headers
+   *   An array of response headers.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  protected function buildWrappedResponse($data, $response_code = 200, array $headers = []) {
+    return new ResourceResponse(new JsonApiDocumentTopLevel($data), $response_code, $headers);
+  }
+
+  /**
+   * Respond with an entity collection.
+   *
+   * @param \Drupal\jsonapi\Resource\EntityCollection $entity_collection
+   *   The collection of entites.
+   * @param string $entity_type_id
+   *   The entity type.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response.
+   */
+  protected function respondWithCollection(EntityCollection $entity_collection, $entity_type_id) {
+    $response = $this->buildWrappedResponse($entity_collection);
+
+    // 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($entity_type_id)
+      ->getListCacheTags();
+    $response->getCacheableMetadata()->addCacheTags($list_tag);
+    return $response;
+  }
+
+  /**
+   * Check the access to update the entity and the presence of a relationship.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param string $operation
+   *   The operation to test.
+   * @param string $related_field
+   *   The name of the field to check.
+   *
+   * @see \Drupal\Core\Access\AccessibleInterface
+   */
+  protected function relationshipAccess(EntityInterface $entity, $operation, $related_field) {
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */
+    $field_access = $entity->{$related_field}->access($operation, NULL, TRUE);
+    $entity_access = $entity->access($operation, NULL, TRUE);
+    $combined_access = $entity_access->andIf($field_access);
+    if (!$combined_access->isAllowed()) {
+      // @todo Is this really the right path?
+      throw new EntityAccessDeniedHttpException($entity, $combined_access, $related_field, "The current user is not allowed to $operation this relationship.");
+    }
+    if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) {
+      throw new NotFoundHttpException(sprintf('The relationship %s is not present in this resource.', $related_field));
+    }
+  }
+
+  /**
+   * Takes a field from the origin entity and puts it to the destination entity.
+   *
+   * @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.
+   */
+  protected function updateEntityField(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.
+      try {
+        $field_name = $this->resourceType->getInternalName($field_name);
+        $destination_field_list = $destination->get($field_name);
+      }
+      catch (\Exception $e) {
+        throw new BadRequestHttpException(sprintf('The provided field (%s) does not exist in the entity with ID %s.', $field_name, $destination->uuid()));
+      }
+
+      $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.');
+    }
+  }
+
+  /**
+   * 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 \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
+   *   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));
+  }
+
+  /**
+   * Checks if is a relationship field.
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface $entity_field
+   *   Entity field.
+   *
+   * @return bool
+   *   Returns TRUE if entity field is a relationship field with non-internal
+   *   target resource types, FALSE otherwise.
+   */
+  protected function isRelationshipField(FieldItemListInterface $entity_field) {
+    $resource_types = $this->resourceType->getRelatableResourceTypesByField(
+      $this->resourceType->getInternalName($entity_field->getName())
+    );
+    return !empty($resource_types) && array_reduce($resource_types, function ($has_external, $resource_type) {
+      return $has_external ? TRUE : !$resource_type->isInternal();
+    }, FALSE);
+  }
+
+  /**
+   * 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 keyed by entity ID containing the keys:
+   *     - entity: the loaded entity or an access exception.
+   *     - access: the access object.
+   */
+  protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids) {
+    $output = [];
+    foreach ($storage->loadMultiple($ids) as $entity) {
+      $output[$entity->id()] = static::getEntityAndAccess($entity);
+    }
+    return $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 array
+   *   An array containing the keys:
+   *     - entity: the loaded entity or an access exception.
+   *     - access: the access object.
+   */
+  public static function getEntityAndAccess(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);
+    // Accumulate the cacheability metadata for the access.
+    $output = [
+      'access' => $access,
+      'entity' => $entity,
+    ];
+    if ($entity instanceof AccessibleInterface && !$access->isAllowed()) {
+      // Pass an exception to the list of things to normalize.
+      $output['entity'] = new EntityAccessDeniedHttpException($entity, $access, '/data', 'The current user is not allowed to GET the selected resource.');
+    }
+
+    return $output;
+  }
+
+  /**
+   * Checks if the given entity exists.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to test existence.
+   *
+   * @return bool
+   *   Whether the entity already has been created.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  protected function entityExists(EntityInterface $entity) {
+    $entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+    return !empty($entity_storage->loadByProperties([
+      'uuid' => $entity->uuid(),
+    ]));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/EntryPoint.php b/core/modules/jsonapi/src/Controller/EntryPoint.php
new file mode 100644
index 0000000..b685ec9
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntryPoint.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Core\Cache\CacheableJsonResponse;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * 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;
+
+  /**
+   * EntryPoint constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The resource type repository.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RendererInterface $renderer) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('jsonapi.resource_type.repository'),
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * Controller to list all the resources.
+   *
+   * @return \Drupal\Core\Cache\CacheableJsonResponse
+   *   The response object.
+   */
+  public function index() {
+    // 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) {
+        // TODO: Learn how to invalidate the cache for this page when a new
+        // entity type or bundle gets added, removed or updated.
+        // $this->response->addCacheableDependency($definition);
+        $url = Url::fromRoute(sprintf('jsonapi.%s.collection', $resource_type->getTypeName()))
+          ->setAbsolute();
+        $carry[$resource_type->getTypeName()] = $url->toString();
+
+        return $carry;
+      }, ['self' => $self->toString()]);
+    };
+    $urls = $this->renderer->executeInRenderContext($context, $do_build_urls);
+
+    $json_response = new CacheableJsonResponse([
+      'data' => [],
+      'links' => $urls,
+    ]
+    );
+
+    if (!$context->isEmpty()) {
+      $json_response->addCacheableDependency($context->pop());
+    }
+
+    return $json_response;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/RequestHandler.php b/core/modules/jsonapi/src/Controller/RequestHandler.php
new file mode 100644
index 0000000..e3ecbf3
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/RequestHandler.php
@@ -0,0 +1,278 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Acts as intermediate request forwarder for resource plugins.
+ *
+ * @internal
+ */
+class RequestHandler {
+
+  /**
+   * The JSON API serializer.
+   *
+   * @var \Drupal\jsonapi\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The current JSON API context.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * 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 field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The field type manager.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $fieldTypeManager;
+
+  /**
+   * The JSON API link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  protected static $requiredCacheContexts = ['user.permissions'];
+
+  /**
+   * Creates a new RequestHandler instance.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The JSON API serializer.
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The current JSON API context.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The 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 $field_type_manager
+   *   The field type manager.
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The JSON API link manager.
+   */
+  public function __construct(SerializerInterface $serializer, CurrentContext $current_context, RendererInterface $renderer, ResourceTypeRepositoryInterface $resource_type_repository, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $field_type_manager, LinkManager $link_manager) {
+    $this->serializer = $serializer;
+    $this->currentContext = $current_context;
+    $this->renderer = $renderer;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->fieldTypeManager = $field_type_manager;
+    $this->linkManager = $link_manager;
+  }
+
+  /**
+   * Handles a web API request.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The route match.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   *
+   * @return \Drupal\Core\Cache\CacheableResponseInterface
+   *   The response object.
+   */
+  public function handle(RouteMatchInterface $route_match, Request $request) {
+    $method = strtolower($request->getMethod());
+    $route = $route_match->getRouteObject();
+
+    // Deserialize incoming data if available.
+    $this->currentContext->reset();
+    $unserialized = $this->deserializeBody($request, $route->getOption('serialization_class'));
+    if ($unserialized instanceof Response && !$unserialized->isSuccessful()) {
+      return $unserialized;
+    }
+
+    // Determine the request parameters that should be passed to the resource
+    // plugin.
+    $route_parameters = $route_match->getParameters();
+    $parameters = [];
+
+    // Filter out all internal parameters starting with "_".
+    foreach ($route_parameters as $key => $parameter) {
+      if ($key{0} !== '_') {
+        $parameters[] = $parameter;
+      }
+    }
+
+    // Invoke the operation on the resource plugin.
+    $action = $this->action($route_match, $method);
+    $resource = $this->resourceFactory($route);
+
+    // Only add the unserialized data if there is something there.
+    $extra_parameters = $unserialized ? [$unserialized, $request] : [$request];
+
+    // 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();
+    $response = $this->renderer
+      ->executeInRenderContext($context, function () use ($resource, $action, $parameters, $extra_parameters) {
+        return call_user_func_array([$resource, $action], array_merge($parameters, $extra_parameters));
+      });
+    $response->getCacheableMetadata()->addCacheContexts(static::$requiredCacheContexts);
+    if (!$context->isEmpty()) {
+      $response->addCacheableDependency($context->pop());
+    }
+
+    return $response;
+  }
+
+  /**
+   * Deserializes the sent data.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   * @param string $serialization_class
+   *   The class the input data needs to deserialize into.
+   *
+   * @return mixed
+   *   The deserialized data or a Response object in case of error.
+   */
+  public function deserializeBody(Request $request, $serialization_class) {
+    $received = $request->getContent();
+    if (empty($received) || $request->isMethodCacheable()) {
+      return NULL;
+    }
+    $resource_type = $this->currentContext->getResourceType();
+    $field_related = $resource_type->getInternalName($request->get('related'));
+    try {
+      return $this->serializer->deserialize($received, $serialization_class, 'api_json', [
+        'related' => $field_related,
+        'target_entity' => $request->get($this->currentContext->getResourceType()->getEntityTypeId()),
+        'resource_type' => $resource_type,
+      ]);
+    }
+    catch (UnexpectedValueException $e) {
+      throw new UnprocessableEntityHttpException(
+        sprintf('There was an error un-serializing the data. Message: %s', $e->getMessage()),
+        $e
+      );
+    }
+  }
+
+  /**
+   * Gets the method to execute in the entity resource.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The route match.
+   * @param string $method
+   *   The lowercase HTTP method.
+   *
+   * @return string
+   *   The method to execute in the EntityResource.
+   */
+  protected function action(RouteMatchInterface $route_match, $method) {
+    $on_relationship = ($route_match->getRouteObject()->getDefault('_on_relationship'));
+    switch ($method) {
+      case 'head':
+      case 'get':
+        if ($on_relationship) {
+          return 'getRelationship';
+        }
+        elseif ($route_match->getParameter('related')) {
+          return 'getRelated';
+        }
+        return $this->getEntity($route_match) ? 'getIndividual' : 'getCollection';
+
+      case 'post':
+        return ($on_relationship) ? 'createRelationship' : 'createIndividual';
+
+      case 'patch':
+        return ($on_relationship) ? 'patchRelationship' : 'patchIndividual';
+
+      case 'delete':
+        return ($on_relationship) ? 'deleteRelationship' : 'deleteIndividual';
+    }
+  }
+
+  /**
+   * Gets the entity for the operation.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The matched route.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The upcasted entity.
+   */
+  protected function getEntity(RouteMatchInterface $route_match) {
+    $route = $route_match->getRouteObject();
+    return $route_match->getParameter($route->getRequirement('_entity_type'));
+  }
+
+  /**
+   * Get the resource.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The matched route.
+   *
+   * @return \Drupal\jsonapi\Controller\EntityResource
+   *   The instantiated resource.
+   */
+  protected function resourceFactory(Route $route) {
+    $resource = new EntityResource(
+      $this->resourceTypeRepository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')),
+      $this->entityTypeManager,
+      $this->fieldManager,
+      $this->currentContext,
+      $this->fieldTypeManager,
+      $this->linkManager,
+      $this->resourceTypeRepository
+    );
+    return $resource;
+  }
+
+}
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..4cf0f7d
--- /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..c46dd28
--- /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..25000e8
--- /dev/null
+++ b/core/modules/jsonapi/src/Encoder/JsonEncoder.php
@@ -0,0 +1,43 @@
+<?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();
+    }
+    // Allows wrapping the encoded output. This is so we can use the same
+    // encoder and normalizers when serializing HttpExceptions to match the
+    // JSON API specification.
+    if (!empty($context['data_wrapper'])) {
+      $data = [$context['data_wrapper'] => $data];
+    }
+    return parent::encode($data, $format, $context);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EntityToJsonApi.php b/core/modules/jsonapi/src/EntityToJsonApi.php
new file mode 100644
index 0000000..9377171
--- /dev/null
+++ b/core/modules/jsonapi/src/EntityToJsonApi.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Serializer\Serializer;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Simplifies the process of generating a JSON API version of an entity.
+ *
+ * @api
+ */
+class EntityToJsonApi {
+
+  /**
+   * The currently logged in user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * Serializer object.
+   *
+   * @var \Drupal\jsonapi\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * EntityToJsonApi constructor.
+   *
+   * @param \Drupal\jsonapi\Serializer\Serializer $serializer
+   *   The serializer.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The resource type repository.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The currently logged in user.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   */
+  public function __construct(Serializer $serializer, ResourceTypeRepositoryInterface $resource_type_repository, AccountInterface $current_user, RequestStack $request_stack) {
+    $this->serializer = $serializer;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->currentUser = $current_user;
+    $this->requestStack = $request_stack;
+  }
+
+  /**
+   * Return the requested entity as a raw string.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to generate the JSON from.
+   *
+   * @return string
+   *   The raw JSON string of the requested resource.
+   */
+  public function serialize(EntityInterface $entity) {
+    // TODO: Supporting includes requires adding the 'include' query string.
+    return $this->serializer->serialize(new JsonApiDocumentTopLevel($entity),
+      'api_json',
+      $this->calculateContext($entity)
+    );
+  }
+
+  /**
+   * Return the requested entity as an structured array.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to generate the JSON from.
+   *
+   * @return array
+   *   The JSON structure of the requested resource.
+   */
+  public function normalize(EntityInterface $entity) {
+    return $this->serializer->normalize(new JsonApiDocumentTopLevel($entity),
+      'api_json',
+      $this->calculateContext($entity)
+    );
+  }
+
+  /**
+   * Calculate the context for the serialize/normalize operation.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to generate the JSON from.
+   *
+   * @return array
+   *   The context.
+   */
+  protected function calculateContext(EntityInterface $entity) {
+    // TODO: Supporting includes requires adding the 'include' query string.
+    $path_prefix = $this->resourceTypeRepository->getPathPrefix();
+    $resource_type = $this->resourceTypeRepository->get(
+      $entity->getEntityTypeId(),
+      $entity->bundle()
+    );
+    $resource_path = $resource_type->getPath();
+    $path = sprintf('/%s/%s/%s', $path_prefix, $resource_path, $entity->uuid());
+    $master_request = $this->requestStack->getMasterRequest();
+    $request = Request::create($master_request->getSchemeAndHttpHost() . $master_request->getBaseUrl() . $path, 'GET');
+    return [
+      'account' => $this->currentUser,
+      'cacheable_metadata' => new CacheableMetadata(),
+      'resource_type' => $resource_type,
+      'request' => $request,
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
new file mode 100644
index 0000000..0f28546
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber as SerializationDefaultExceptionSubscriber;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+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) {
+    /** @var \Symfony\Component\HttpKernel\Exception\HttpException $exception */
+    $exception = $event->getException();
+    if (!$this->isJsonApiFormatted($event->getRequest())) {
+      return;
+    }
+    if (!$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();
+    if (!$this->isJsonApiFormatted($event->getRequest())) {
+      return;
+    }
+    $encoded_content = $this->serializer->serialize($exception, 'api_json', ['data_wrapper' => 'errors']);
+    $response = new Response($encoded_content, $status);
+    $response->headers->set('Content-Type', 'application/vnd.api+json');
+    $event->setResponse($response);
+  }
+
+  /**
+   * Check if the error should be formatted using JSON API.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The failed request.
+   *
+   * @return bool
+   *   TRUE if it needs to be formated using JSON API. FALSE otherwise.
+   */
+  protected function isJsonApiFormatted(Request $request) {
+    $route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT);
+    $format = $request->getRequestFormat();
+    // The JSON API format is supported if the format is explicitly set or the
+    // request is for a known JSON API route.
+    return $format === 'api_json' || ($route && $route->getOption('_is_jsonapi'));
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php
new file mode 100644
index 0000000..8fddd81
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/ResourceResponseSubscriber.php
@@ -0,0 +1,368 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\CacheableResponse;
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\ResourceResponse;
+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 serializes and removes ResourceResponses' data.
+ *
+ * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
+ * @internal
+ *
+ * This is 99% identical to:
+ *
+ * \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
+ *
+ * but with a few differences:
+ * 1. It has the @jsonapi.serializer service injected instead of @serializer
+ * 2. It has the @current_route_match service no longer injected
+ * 3. It hardcodes the format to 'api_json'
+ * 4. In the call to the serializer, it passes in the request and cacheable
+ *    metadata as serialization context.
+ * 5. It validates the final response according to the JSON API JSON schema
+ * 6. It has a different priority, to ensure it runs before the Dynamic Page
+ *    Cache event subscriber — but this should also be fixed in the original
+ *    class, see issue
+ */
+class ResourceResponseSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The serializer.
+   *
+   * @var \Symfony\Component\Serializer\SerializerInterface
+   */
+  protected $serializer;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * 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 ResourceResponseSubscriber object.
+   *
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @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, RendererInterface $renderer, LoggerInterface $logger, ModuleHandlerInterface $module_handler, $app_root) {
+    $this->serializer = $serializer;
+    $this->renderer = $renderer;
+    $this->logger = $logger;
+    $this->moduleHandler = $module_handler;
+    $this->appRoot = $app_root;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    // This needs to be run before the dynamic_page_cache subscriber.
+    $events[KernelEvents::RESPONSE][] = ['onResponse', 110];
+    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;
+  }
+
+  /**
+   * 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));
+
+    $this->doValidateResponse($response, $request);
+  }
+
+  /**
+   * 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');
+    }
+  }
+
+  /**
+   * 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) {
+      $context = new RenderContext();
+      $render_function = function () use ($serializer, $data, $format, $request, $response) {
+        // The serializer receives the response's cacheability metadata object
+        // as serialization context. Normalizers called by the serializer then
+        // refine this cacheability metadata, and thus they are effectively
+        // updating the response object's cacheability.
+        return $serializer->serialize($data, $format, [
+          'request' => $request,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+        ]);
+      };
+      $output = $this->renderer->executeInRenderContext($context, $render_function);
+
+      if ($response instanceof CacheableResponseInterface && !$context->isEmpty()) {
+        $response->addCacheableDependency($context->pop());
+      }
+
+      $response->setContent($output);
+      $response->headers->set('Content-Type', $request->getMimeType($format));
+    }
+  }
+
+  /**
+   * 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;
+  }
+
+  /**
+   * 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.
+    $route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT);
+    $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 = $route->getRequirement('_entity_type');
+    $bundle = $route->getRequirement('_bundle');
+    $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..9cd844c
--- /dev/null
+++ b/core/modules/jsonapi/src/Exception/EntityAccessDeniedHttpException.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\jsonapi\Exception;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Enhances the access denied exception with information about the entity.
+ *
+ * @internal
+ */
+class EntityAccessDeniedHttpException extends HttpException {
+
+  use DependencySerializationTrait;
+
+  /**
+   * 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 $messsage
+   *   (Optional) The display to display.
+   * @param \Exception|null $previous
+   *   The previous exception.
+   * @param array $headers
+   *   The headers.
+   * @param int $code
+   *   The code.
+   */
+  public function __construct($entity, AccessResultInterface $entity_access, $pointer, $messsage = 'The current user is not allowed to GET the selected resource.', \Exception $previous = NULL, array $headers = [], $code = 0) {
+    assert(is_null($entity) || $entity instanceof EntityInterface);
+    parent::__construct(403, $messsage, $previous, $headers, $code);
+
+    $error = [
+      'entity' => $entity,
+      'pointer' => $pointer,
+      'reason' => NULL,
+    ];
+    if ($entity_access instanceof AccessResultReasonInterface) {
+      $error['reason'] = $entity_access->getReason();
+    }
+    $this->error = $error;
+  }
+
+  /**
+   * 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/Field/FileDownloadUrl.php b/core/modules/jsonapi/src/Field/FileDownloadUrl.php
new file mode 100644
index 0000000..29bb5e3
--- /dev/null
+++ b/core/modules/jsonapi/src/Field/FileDownloadUrl.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\jsonapi\Field;
+
+use Drupal\Core\Field\FieldItemList;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\Core\TypedData\ComputedItemListTrait;
+
+/**
+ * Extends core URL field functionality.
+ *
+ * @internal
+ */
+class FileDownloadUrl extends FieldItemList {
+
+  use ComputedItemListTrait;
+
+  /**
+   * Creates a relative URL out of a URI.
+   *
+   * This is a wrapper to the procedural code for testing purposes. For obvious
+   * reasons this method will not be unit tested, but that is fine since it's
+   * only using already tested Drupal API functions.
+   *
+   * @param string $uri
+   *   The URI to transform.
+   *
+   * @return string
+   *   The transformed relative URL.
+   */
+  protected function fileCreateRootRelativeUrl($uri) {
+    $wrapper = \Drupal::service('stream_wrapper_manager')->getViaUri($uri);
+    if ($wrapper && ($wrapper->getType() & StreamWrapperInterface::VISIBLE)) {
+      return file_url_transform_relative(file_create_url($uri));
+    }
+
+    // For testing purposes, return the $uri when the scheme is not a wrapper or
+    // not visible.
+    return $uri;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
+    return $this->getEntity()
+      ->get('uri')
+      ->access($operation, $account, $return_as_object);
+  }
+
+  /**
+   * Initialize the internal field list with the modified items.
+   */
+  protected function computeValue() {
+    $url_list = [];
+    foreach ($this->getEntity()->get('uri') as $delta => $uri_item) {
+      $path = $this->fileCreateRootRelativeUrl($uri_item->value);
+      $url_list[$delta] = $this->createItem($delta, $path);
+    }
+    $this->list = $url_list;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonApiSpec.php b/core/modules/jsonapi/src/JsonApiSpec.php
new file mode 100644
index 0000000..cce804b
--- /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/LinkManager/LinkManager.php b/core/modules/jsonapi/src/LinkManager/LinkManager.php
new file mode 100644
index 0000000..ad6a78f
--- /dev/null
+++ b/core/modules/jsonapi/src/LinkManager/LinkManager.php
@@ -0,0 +1,198 @@
+<?php
+
+namespace Drupal\jsonapi\LinkManager;
+
+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 Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
+
+/**
+ * Class to generate links and queries for entities.
+ *
+ * @deprecated
+ */
+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 \Symfony\Component\Routing\Matcher\RequestMatcherInterface|null $_router
+   *   Unused. Kept for backwards compatibility.
+   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
+   *   The Url generator.
+   */
+  public function __construct(RequestMatcherInterface $_router = NULL, 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
+   *   The URL string.
+   */
+  public function getEntityLink($entity_id, ResourceType $resource_type, array $route_parameters, $key) {
+    $route_parameters += [
+      $resource_type->getEntityTypeId() => $entity_id,
+    ];
+    $route_key = sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $key);
+    return $this->urlGenerator->generateFromRoute($route_key, $route_parameters, ['absolute' => TRUE]);
+  }
+
+  /**
+   * 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) {
+      throw new BadRequestHttpException(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'] = $this->getRequestLink($request, $this->getPagerQueries('next', $offset, $size, $query));
+
+      if (!empty($total)) {
+        $links['last'] = $this->getRequestLink($request, $this->getPagerQueries('last', $offset, $size, $query, $total));
+      }
+    }
+    // Check if this is not the first page.
+    if ($offset > 0) {
+      $links['first'] = $this->getRequestLink($request, $this->getPagerQueries('first', $offset, $size, $query));
+      $links['prev'] = $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/CacheableDependencyTrait.php b/core/modules/jsonapi/src/Normalizer/CacheableDependencyTrait.php
new file mode 100644
index 0000000..d750ac9
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/CacheableDependencyTrait.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+
+/**
+ * Trait for \Drupal\Core\Cache\CacheableDependencyInterface.
+ *
+ * @internal
+ * @deprecated Remove when JSON API requires Drupal 8.5 or newer, update all users to \Drupal\Core\Cache\CacheableDependencyTrait instead.
+ */
+trait CacheableDependencyTrait {
+
+  /**
+   * Cache contexts.
+   *
+   * @var string[]
+   */
+  protected $cacheContexts = [];
+
+  /**
+   * Cache tags.
+   *
+   * @var string[]
+   */
+  protected $cacheTags = [];
+
+  /**
+   * Cache max-age.
+   *
+   * @var int
+   */
+  protected $cacheMaxAge = Cache::PERMANENT;
+
+  /**
+   * Sets cacheability; useful for value object constructors.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cacheability to set.
+   *
+   * @return $this
+   */
+  protected function setCacheability(CacheableDependencyInterface $cacheability) {
+    $this->cacheContexts = $cacheability->getCacheContexts();
+    $this->cacheTags = $cacheability->getCacheTags();
+    $this->cacheMaxAge = $cacheability->getCacheMaxAge();
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    return $this->cacheTags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return $this->cacheContexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return $this->cacheMaxAge;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
new file mode 100644
index 0000000..0345fa3
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
@@ -0,0 +1,82 @@
+<?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 = static::getDataWithoutInternals($entity->toArray());
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(
+      array_keys($fields),
+      [$resource_type, 'isFieldEnabled']
+    );
+    // 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'
+    );
+  }
+
+  /**
+   * Gets the given data without the internal implementation details.
+   *
+   * @param array $data
+   *   The data that is either currently or about to be stored in configuration.
+   *
+   * @return array
+   *   The same data, but without internals. Currently, that is only the '_core'
+   *   key, which 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
+   * @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::getDataWithoutInternals
+   */
+  protected static function getDataWithoutInternals(array $data) {
+    return array_diff_key($data, ['_core' => TRUE]);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php
new file mode 100644
index 0000000..b88cd09
--- /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..e9077dc
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityAccessDeniedHttpExceptionNormalizer.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+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'];
+
+      if (isset($entity)) {
+        $errors[0]['id'] = sprintf(
+          '/%s--%s/%s',
+          $entity->getEntityTypeId(),
+          $entity->bundle(),
+          $entity->uuid()
+        );
+      }
+      $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..60b14e9
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityConditionNormalizer.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\EntityCondition;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * 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]);
+
+    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 BadRequestHttpException("Filter parameter is missing a '" . static::PATH_KEY . "' key.");
+        }
+        if (!$has_value_key) {
+          throw new BadRequestHttpException("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 BadRequestHttpException($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 BadRequestHttpException($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 BadRequestHttpException($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..7f62006
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+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\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;
+
+  /**
+   * 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.
+   */
+  public function __construct(LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository, EntityTypeManagerInterface $entity_type_manager) {
+    $this->linkManager = $link_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->entityTypeManager = $entity_type_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) {
+      if (!in_array($field_name, $field_names)) {
+        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[$bundle_key] = $bundle;
+    }
+
+    return $this->entityTypeManager->getStorage($entity_type_id)
+      ->create($this->prepareInput($data, $resource_type));
+  }
+
+  /**
+   * 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 = [];
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) >= 8.5) {
+      $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
+    }
+    else {
+      $fields = $entity->getFields();
+    }
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(
+      array_keys($fields),
+      [$resource_type, 'isFieldEnabled']
+    );
+    // 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.
+   *
+   * @return array
+   *   The modified input data.
+   */
+  protected function prepareInput(array $data, ResourceType $resource_type) {
+    $data_internal = [];
+    // Translate the public fields into the entity fields.
+    foreach ($data as $public_field_name => $field_value) {
+      // Skip any disabled field.
+      if (!$resource_type->isFieldEnabled($public_field_name)) {
+        continue;
+      }
+      $internal_name = $resource_type->getInternalName($public_field_name);
+      $data_internal[$internal_name] = $field_value;
+    }
+
+    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..7d90ca8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -0,0 +1,265 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Normalizer class specific for entity reference field objects.
+ *
+ * @internal
+ */
+class EntityReferenceFieldNormalizer extends FieldNormalizer implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The field plugin manager.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * The entity repository.
+   *
+   * @var \Drupal\Core\Entity\EntityRepositoryInterface
+   */
+  protected $entityRepository;
+
+  /**
+   * Instantiates a EntityReferenceFieldNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
+   *   The plugin manager for fields.
+   * @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(LinkManager $link_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager, ResourceTypeRepositoryInterface $resource_type_repository, EntityRepositoryInterface $entity_repository) {
+    $this->linkManager = $link_manager;
+    $this->fieldManager = $field_manager;
+    $this->pluginManager = $plugin_manager;
+    $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');
+    }
+
+    // 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();
+    /** @var \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem[] $entity_reference_item_list */
+    $entity_reference_item_list = array_filter(iterator_to_array($field), function ($item) {
+      return (bool) $item->get('entity')->getValue();
+    });
+    $entity_list_metadata = [];
+    $entity_list = [];
+    foreach ($entity_reference_item_list as $item) {
+      // Prepare a list of additional properties stored by the field.
+      $metadata = [];
+      /** @var \Drupal\Core\TypedData\TypedDataInterface[] $properties */
+      // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+      $properties = (floatval(\Drupal::VERSION) < 8.5)
+        ? $item->getProperties()
+        : TypedDataInternalPropertiesHelper::getNonInternalProperties($item);
+      foreach ($properties as $property_key => $property) {
+        if ($property_key !== $main_property) {
+          $metadata[$property_key] = $property->getValue();
+        }
+      }
+      $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);
+    $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $entity_collection, $field->getEntity(), $field_access, $cardinality, $main_property, $entity_list_metadata);
+    return $this->serializer->normalize($relationship, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    // If we get to here is through a write method on a relationship operation.
+    /** @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_resources = $this->getAllowedResourceTypes($item_definition);
+
+    $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
+    $data = $this->massageRelationshipInput($data, $is_multiple);
+    $values = array_map(function ($value) use ($property_key, $target_resources) {
+      // Make sure that the provided type is compatible with the targeted
+      // resource.
+      if (!in_array($value['type'], $target_resources)) {
+        throw new BadRequestHttpException(sprintf(
+          'The provided type (%s) does not mach the destination resource types (%s).',
+          $value['type'],
+          implode(', ', $target_resources)
+        ));
+      }
+
+      // Load the entity by UUID.
+      list($entity_type_id,) = explode('--', $value['type']);
+      $entity = $this->entityRepository->loadEntityByUuid($entity_type_id, $value['id']);
+      $value['id'] = $entity ? $entity->id() : NULL;
+
+      $properties = [$property_key => $value['id']];
+      // Also take into account additional properties provided by the field
+      // type.
+      if (!empty($value['meta'])) {
+        foreach ($value['meta'] as $meta_key => $meta_value) {
+          $properties[$meta_key] = $meta_value;
+        }
+      }
+      return $properties;
+    }, $data['data']);
+    return $this->pluginManager
+      ->createFieldItemList($context['target_entity'], $context['related'], $values);
+  }
+
+  /**
+   * 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;
+  }
+
+  /**
+   * 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();
+  }
+
+  /**
+   * Build the list of resource types supported by this entity reference field.
+   *
+   * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition
+   *   The field item definition.
+   *
+   * @return string[]
+   *   List of resource types.
+   */
+  protected function getAllowedResourceTypes(FieldItemDataDefinition $item_definition) {
+    // Build the list of allowed resources.
+    $target_entity_id = $item_definition->getSetting('target_type');
+    $handler_settings = $item_definition->getSetting('handler_settings');
+    $target_bundles = empty($handler_settings['target_bundles']) ?
+      [] :
+      $handler_settings['target_bundles'];
+    return array_map(function ($target_bundle) use ($target_entity_id) {
+      return $this->resourceTypeRepository
+        ->get($target_entity_id, $target_bundle)
+        ->getTypeName();
+    }, $target_bundles);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
new file mode 100644
index 0000000..388741a
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Converts the Drupal field item object to a JSON API array structure.
+ *
+ * @internal
+ */
+class FieldItemNormalizer extends NormalizerBase {
+
+  /**
+   * 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.
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) >= 8.5) {
+      $field_item = TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item);
+    }
+
+    // @todo Use the constant \Drupal\serialization\Normalizer\CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY instead of the 'cacheability' string when JSON API requires Drupal 8.5 or newer.
+    $context['cacheability'] = new CacheableMetadata();
+
+    foreach ($field_item as $property_name => $property) {
+      $values[$property_name] = $this->serializer->normalize($property, $format, $context);
+    }
+
+    if (isset($context['langcode'])) {
+      $values['lang'] = $context['langcode'];
+    }
+    // @todo Use the constant \Drupal\serialization\Normalizer\CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY instead of the 'cacheability' string when JSON API requires Drupal 8.5 or newer.
+    $value = new FieldItemNormalizerValue($values, $context['cacheability']);
+    unset($context['cacheability']);
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    throw new UnexpectedValueException('Denormalization not implemented for JSON API');
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
new file mode 100644
index 0000000..fd354d8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+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\Exception\UnexpectedValueException;
+
+/**
+ * Converts the Drupal field structure to a JSON API array structure.
+ *
+ * @internal
+ */
+class FieldNormalizer extends NormalizerBase {
+
+  /**
+   * 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 = []) {
+    throw new UnexpectedValueException('Denormalization not implemented for JSON API');
+  }
+
+  /**
+   * 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..46f03b88
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FilterNormalizer.php
@@ -0,0 +1,248 @@
+<?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) {
+      // 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->resolveInternal(
+        $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..9a61d44
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
@@ -0,0 +1,170 @@
+<?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(),
+    ];
+    if ($info_url = $this->getInfoUrl($status_code)) {
+      $error['links']['info'] = $info_url;
+    }
+    $error['code'] = $exception->getCode();
+    // 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..2096b12
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -0,0 +1,320 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Uuid\Uuid;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+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\Resource\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 current JSON API request context.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The field resolver.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $fieldResolver;
+
+  /**
+   * Constructs a JsonApiDocumentTopLevelNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager to get the links.
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The current context.
+   * @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.
+   * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
+   *   The JSON API field resolver.
+   */
+  public function __construct(LinkManager $link_manager, CurrentContext $current_context, EntityTypeManagerInterface $entity_type_manager, ResourceTypeRepositoryInterface $resource_type_repository, FieldResolver $field_resolver) {
+    $this->linkManager = $link_manager;
+    $this->currentContext = $current_context;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->fieldResolver = $field_resolver;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    // Validate a few common errors in document formatting.
+    $this->validateRequestBody($data);
+
+    $context += [
+      'on_relationship' => $this->currentContext->isOnRelationship(),
+    ];
+    $normalized = [];
+
+    if (!empty($data['data']['attributes'])) {
+      $normalized = $data['data']['attributes'];
+    }
+
+    if (!empty($data['data']['id'])) {
+      $resource_type = $this->resourceTypeRepository->getByTypeName($data['data']['type']);
+      $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 (empty($map[$uuid])) {
+            continue;
+          }
+          $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 = []) {
+    if (empty($context['resource_type'])) {
+      $context['resource_type'] = $this->currentContext->getResourceType();
+    }
+    $value_extractor = $this->buildNormalizerValue($object->getData(), $format, $context);
+    if (!empty($context['cacheable_metadata'])) {
+      $context['cacheable_metadata']->addCacheableDependency($value_extractor);
+    }
+    $normalized = $value_extractor->rasterizeValue();
+    $included = array_filter($value_extractor->rasterizeIncludes());
+    if (!empty($included)) {
+      foreach ($included as $included_item) {
+        if ($included_item['data'] === FALSE) {
+          unset($included_item['data']);
+          $normalized = NestedArray::mergeDeep($normalized, $included_item);
+        }
+        else {
+          $normalized['included'][] = $included_item['data'];
+        }
+      }
+    }
+
+    return $normalized;
+  }
+
+  /**
+   * Build the normalizer value.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue
+   *   The normalizer value.
+   */
+  public function buildNormalizerValue($data, $format = NULL, array $context = []) {
+    if (empty($context['expanded'])) {
+      $context += $this->expandContext($context['request'], $context['resource_type']);
+    }
+
+    if ($data instanceof EntityReferenceFieldItemListInterface) {
+      return $this->serializer->normalize($data, $format, $context);
+    }
+    $is_collection = $data instanceof EntityCollection;
+    $include_count = $context['resource_type']->includeCount();
+    // To improve the logical workflow deal with an array at all times.
+    $entities = $is_collection ? $data->toArray() : [$data];
+    $context['has_next_page'] = $is_collection ? $data->hasNextPage() : FALSE;
+
+    if ($include_count) {
+      $context['total_count'] = $is_collection ? $data->getTotalCount() : 1;
+    }
+    $serializer = $this->serializer;
+    $normalizer_values = array_map(function ($entity) use ($format, $context, $serializer) {
+      return $serializer->normalize($entity, $format, $context);
+    }, $entities);
+
+    $link_context = [
+      'link_manager' => $this->linkManager,
+      'has_next_page' => $context['has_next_page'],
+    ];
+
+    if ($include_count) {
+      $link_context['total_count'] = $context['total_count'];
+    }
+
+    return new JsonApiDocumentTopLevelNormalizerValue($normalizer_values, $context, $link_context, $is_collection);
+  }
+
+  /**
+   * Expand the context information based on the current request context.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request to get the URL params from to expand the context.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type to translate to internal fields.
+   *
+   * @return array
+   *   The expanded context.
+   */
+  protected function expandContext(Request $request, ResourceType $resource_type) {
+    // Translate ALL the includes from the public field names to the internal.
+    $includes = array_filter(explode(',', $request->query->get('include')));
+    $public_includes = array_map(function ($include_str) use ($resource_type) {
+      $resolved = $this->fieldResolver->resolveInternal(
+        $resource_type->getEntityTypeId(),
+        $resource_type->getBundle(),
+        trim($include_str)
+      );
+      // We don't need the entity information for the includes. Clean it.
+      return preg_replace('/\.entity\./', '.', $resolved);
+    }, $includes);
+    // Build the expanded context.
+    $context = [
+      'account' => NULL,
+      'sparse_fieldset' => NULL,
+      'resource_type' => NULL,
+      'include' => $public_includes,
+      'expanded' => TRUE,
+    ];
+    if ($request->query->get('fields')) {
+      $context['sparse_fieldset'] = array_map(function ($item) {
+        return explode(',', $item);
+      }, $request->query->get('fields'));
+    }
+
+    return $context;
+  }
+
+  /**
+   * Performs mimimal validation of the document.
+   */
+  protected static function validateRequestBody(array $document) {
+    // 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'])) {
+      // This should be a 422 response, but the JSON API specification dictates
+      // a 403 Forbidden response. We follow the specification.
+      throw new EntityAccessDeniedHttpException(NULL, AccessResult::forbidden(), '/data/id', 'IDs should be properly generated and formatted UUIDs as described in RFC 4122.');
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/NormalizerBase.php b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
new file mode 100644
index 0000000..24b01a4
--- /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..8dba9d1
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/OffsetPageNormalizer.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\OffsetPage;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * 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)) {
+      throw new BadRequestHttpException('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..57b3661
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Relationship.php
@@ -0,0 +1,162 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessibleInterface;
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Resource\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\Resource\EntityCollection $entities
+   *   A collection of entities.
+   * @param \Drupal\Core\Entity\EntityInterface $host_entity
+   *   The host entity.
+   * @param \Drupal\Core\Access\AccessResultInterface $view_access
+   *   The 'view' field access result. (This value object is only ever used for
+   *   normalization, and hence only for 'view' access.
+   * @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, AccessResultInterface $view_access, $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($view_access);
+
+    $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..3a7a9b9
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+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
+   */
+  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 $target_entity
+   *   The entity this relationship points to.
+   * @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, EntityInterface $target_entity, Relationship $parent, $target_key = 'target_id', array $metadata = []) {
+    $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
+   *   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() {
+    return [
+      'target_uuid' => $this->getTargetEntity()->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..557bc02
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Controller\EntityResource;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Converts the Drupal entity reference item object to a JSON API structure.
+ *
+ * @todo Remove the dependency on \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+ *
+ * @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;
+
+  /**
+   * The JSON API document top level normalizer.
+   *
+   * @var \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+   */
+  protected $jsonapiDocumentToplevelNormalizer;
+
+  /**
+   * Instantiates a RelationshipItemNormalizer object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer $jsonapi_document_toplevel_normalizer
+   *   The document root normalizer for the include.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, JsonApiDocumentTopLevelNormalizer $jsonapi_document_toplevel_normalizer) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->jsonapiDocumentToplevelNormalizer = $jsonapi_document_toplevel_normalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo Remove this override when the dependency on \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer is removed.
+   */
+  public function setSerializer(SerializerInterface $serializer) {
+    parent::setSerializer($serializer);
+    $this->jsonapiDocumentToplevelNormalizer->setSerializer($serializer);
+  }
+
+  /**
+   * {@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.
+    $target_entity = $relationship_item->getTargetEntity();
+    $values = $relationship_item->getValue();
+    if (isset($context['langcode'])) {
+      $values['lang'] = $context['langcode'];
+    }
+
+    $host_field_name = $relationship_item->getParent()->getPropertyName();
+    if (!empty($context['include']) && in_array($host_field_name, $context['include'])) {
+      $context = $this->buildSubContext($context, $target_entity, $host_field_name);
+      $entity_and_access = EntityResource::getEntityAndAccess($target_entity);
+      $included_normalizer_value = $this
+        ->jsonapiDocumentToplevelNormalizer
+        ->buildNormalizerValue($entity_and_access['entity'], $format, $context);
+    }
+    else {
+      $included_normalizer_value = NULL;
+    }
+
+    return new RelationshipItemNormalizerValue(
+      $values,
+      new CacheableMetadata(),
+      $relationship_item->getTargetResourceType(),
+      $included_normalizer_value
+    );
+  }
+
+  /**
+   * Builds the sub-context for the relationship include.
+   *
+   * @param array $context
+   *   The serialization context.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The related entity.
+   * @param string $host_field_name
+   *   The name of the field reference.
+   *
+   * @return array
+   *   The modified new context.
+   */
+  protected function buildSubContext(array $context, EntityInterface $entity, $host_field_name) {
+    // Swap out the context for the context of the referenced resource.
+    $context['resource_type'] = $this->resourceTypeRepository
+      ->get($entity->getEntityTypeId(), $entity->bundle());
+    // Since we're going one level down the only includes we need are the ones
+    // that apply to this level as well.
+    $include_candidates = array_filter($context['include'], function ($include) use ($host_field_name) {
+      return strpos($include, $host_field_name . '.') === 0;
+    });
+    $context['include'] = array_map(function ($include) use ($host_field_name) {
+      return str_replace($host_field_name . '.', '', $include);
+    }, $include_candidates);
+    return $context;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
new file mode 100644
index 0000000..8698c4e
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * 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 {
+
+  /**
+   * 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 link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * 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.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, LinkManager $link_manager) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->linkManager = $link_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    throw new UnexpectedValueException('Denormalization not implemented for JSON API');
+  }
+
+  /**
+   * 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();
+    $link_context = [
+      'host_entity_id' => $relationship->getHostEntity()->uuid(),
+      'field_name' => $relationship->getPropertyName(),
+      'link_manager' => $this->linkManager,
+      'resource_type' => $context['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);
+  }
+
+  /**
+   * Builds the sub-context for the relationship include.
+   *
+   * @param array $context
+   *   The serialization context.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The related entity.
+   * @param string $host_field_name
+   *   The name of the field reference.
+   *
+   * @return array
+   *   The modified new context.
+   *
+   * @see EntityReferenceItemNormalizer::buildSubContext()
+   * @todo This is duplicated code from the reference item. Reuse code instead.
+   */
+  protected function buildSubContext(array $context, EntityInterface $entity, $host_field_name) {
+    // Swap out the context for the context of the referenced resource.
+    $context['resource_type'] = $this->resourceTypeRepository
+      ->get($entity->getEntityTypeId(), $entity->bundle());
+    // Since we're going one level down the only includes we need are the ones
+    // that apply to this level as well.
+    $include_candidates = array_filter($context['include'], function ($include) use ($host_field_name) {
+      return strpos($include, $host_field_name . '.') === 0;
+    });
+    $context['include'] = array_map(function ($include) use ($host_field_name) {
+      return str_replace($host_field_name . '.', '', $include);
+    }, $include_candidates);
+    return $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..3652b73
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/SortNormalizer.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Context\FieldResolver;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * 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->resolveInternal(
+        $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)) {
+      throw new BadRequestHttpException('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) {
+    $defaults = [
+      Sort::DIRECTION_KEY => 'ASC',
+      Sort::LANGUAGE_KEY => NULL,
+    ];
+
+    if (!isset($sort_item[Sort::PATH_KEY])) {
+      throw new BadRequestHttpException('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 BadRequestHttpException('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..950235b
--- /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..7b696d1
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Normalizer\CacheableDependencyTrait;
+
+/**
+ * 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 includes.
+   *
+   * @var array
+   */
+  protected $includes;
+
+  /**
+   * 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'];
+    // Get an array of arrays of includes.
+    $this->includes = array_map(function ($value) {
+      return $value->getIncludes();
+    }, $values);
+    // Flatten the includes.
+    $this->includes = array_reduce($this->includes, function ($carry, $includes) {
+      return array_merge($carry, $includes);
+    }, []);
+    // Filter the empty values.
+    $this->includes = array_filter($this->includes);
+  }
+
+  /**
+   * {@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' => $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);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeIncludes() {
+    // First gather all the includes in the chain.
+    return array_map(function ($include) {
+      return $include->rasterizeValue();
+    }, $this->getIncludes());
+  }
+
+  /**
+   * Gets the values.
+   *
+   * @return mixed
+   *   The values.
+   */
+  public function getValues() {
+    return $this->values;
+  }
+
+  /**
+   * Gets a flattened list of includes in all the chain.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue[]
+   *   The array of included relationships.
+   */
+  public function getIncludes() {
+    $nested_includes = array_map(function ($include) {
+      return $include->getIncludes();
+    }, $this->includes);
+    return array_reduce(array_filter($nested_includes), function ($carry, $item) {
+      return array_merge($carry, $item);
+    }, $this->includes);
+  }
+
+}
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..e5a3830
--- /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\jsonapi\Normalizer\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..0c2bc62
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\jsonapi\Normalizer\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 includes.
+   *
+   * @var array
+   */
+  protected $includes;
+
+  /**
+   * 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->includes = array_map(function ($value) {
+      if (!$value instanceof RelationshipItemNormalizerValue) {
+        return NULL;
+      }
+      return $value->getInclude();
+    }, $values);
+    $this->includes = array_filter($this->includes);
+    $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 rasterizeIncludes() {
+    return array_map(function ($include) {
+      return $include->rasterizeValue();
+    }, $this->includes);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIncludes() {
+    return $this->includes;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPropertyType() {
+    return $this->propertyType;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllIncludes() {
+    $nested_includes = array_map(function ($include) {
+      return $include->getIncludes();
+    }, $this->getIncludes());
+    $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) {
+      return array_merge($carry, $item);
+    }, $this->getIncludes());
+    // Make sure we don't output duplicate includes.
+    return array_values(array_reduce($includes, function ($unique_includes, $include) {
+      $rasterized_include = $include->rasterizeValue();
+      $unique_includes[$rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id']] = $include;
+      return $unique_includes;
+    }, []));
+  }
+
+}
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..445c30c
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php
@@ -0,0 +1,38 @@
+<?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 includes.
+   *
+   * @return mixed
+   *   The includes.
+   */
+  public function getIncludes();
+
+  /**
+   * Gets the propertyType.
+   *
+   * @return mixed
+   *   The propertyType.
+   */
+  public function getPropertyType();
+
+  /**
+   * Computes all the nested includes recursively.
+   *
+   * @return array
+   *   The includes and the nested includes.
+   */
+  public function getAllIncludes();
+
+}
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..40fcdd6
--- /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..a03d71d
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+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;
+
+  /**
+   * The values.
+   *
+   * @var array
+   */
+  protected $values;
+
+  /**
+   * The includes.
+   *
+   * @var array
+   */
+  protected $includes;
+
+  /**
+   * The resource path.
+   *
+   * @var array
+   */
+  protected $context;
+
+  /**
+   * Is collection?
+   *
+   * @var bool
+   */
+  protected $isCollection;
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The link context.
+   *
+   * @var array
+   */
+  protected $linkContext;
+
+  /**
+   * Instantiates a JsonApiDocumentTopLevelNormalizerValue object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface[] $values
+   *   The data to normalize. It can be either a straight up entity or a
+   *   collection of entities.
+   * @param array $context
+   *   The context.
+   * @param array $link_context
+   *   All the objects and variables needed to generate the links for this
+   *   relationship.
+   * @param bool $is_collection
+   *   TRUE if this is a serialization for a list.
+   */
+  public function __construct(array $values, array $context, array $link_context, $is_collection = FALSE) {
+    $this->values = $values;
+    array_walk($values, [$this, 'addCacheableDependency']);
+    // 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);
+    }, JsonApiSpec::getReservedQueryParameters()));
+
+    $this->context = $context;
+    $this->isCollection = $is_collection;
+    $this->linkManager = $link_context['link_manager'];
+    // Remove the manager and store the link context.
+    unset($link_context['link_manager']);
+    $this->linkContext = $link_context;
+    // Get an array of arrays of includes.
+    $this->includes = array_map(function ($value) {
+      return $value->getIncludes();
+    }, $values);
+    // Flatten the includes.
+    $this->includes = array_reduce($this->includes, function ($carry, $includes) {
+      array_walk($includes, [$this, 'addCacheableDependency']);
+      return array_merge($carry, $includes);
+    }, []);
+    // Filter the empty values.
+    $this->includes = array_filter($this->includes);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    // Create the array of normalized fields, starting with the URI.
+    $rasterized = [
+      'data' => [],
+      'jsonapi' => [
+        'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
+        'meta' => [
+          'links' => ['self' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK],
+        ],
+      ],
+    ];
+
+    foreach ($this->values as $normalizer_value) {
+      if ($normalizer_value instanceof HttpExceptionNormalizerValue) {
+        $previous_errors = NestedArray::getValue($rasterized, ['meta', 'errors']) ?: [];
+        // Add the errors to the pre-existing errors.
+        $rasterized['meta']['errors'] = array_merge($previous_errors, $normalizer_value->rasterizeValue());
+      }
+      else {
+        $rasterized['data'][] = $normalizer_value->rasterizeValue();
+      }
+    }
+    $rasterized['data'] = array_filter($rasterized['data']);
+    // Deal with the single entity case.
+    $rasterized['data'] = $this->isCollection ?
+      $rasterized['data'] :
+      reset($rasterized['data']);
+
+    // Add the self link.
+    if ($this->context['request']) {
+      /* @var \Symfony\Component\HttpFoundation\Request $request */
+      $request = $this->context['request'];
+      $rasterized['links'] = [
+        'self' => $this->linkManager->getRequestLink($request),
+      ];
+      // If this is a collection we need to append the pager data.
+      if ($this->isCollection) {
+        // Add the pager links.
+        $rasterized['links'] += $this->linkManager->getPagerLinks($request, $this->linkContext);
+
+        // Add the pre-calculated total count to the meta section.
+        if (isset($this->context['total_count'])) {
+          $rasterized = NestedArray::mergeDeepArray([
+            $rasterized,
+            ['meta' => ['count' => $this->context['total_count']]],
+          ]);
+        }
+      }
+    }
+    return $rasterized;
+  }
+
+  /**
+   * Gets a flattened list of includes in all the chain.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue[]
+   *   The array of included relationships.
+   */
+  public function getIncludes() {
+    $nested_includes = array_map(function ($include) {
+      return $include->getIncludes();
+    }, $this->includes);
+    $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) {
+      return array_merge($carry, $item);
+    }, $this->includes);
+    // Make sure we don't output duplicate includes.
+    return array_values(array_reduce($includes, function ($unique_includes, $include) {
+      $rasterized_include = $include->rasterizeValue();
+
+      if ($rasterized_include['data'] === FALSE) {
+        $unique_includes[] = $include;
+      }
+      else {
+        $unique_key = $rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id'];
+        $unique_includes[$unique_key] = $include;
+      }
+      return $unique_includes;
+    }, []));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeIncludes() {
+    // First gather all the includes in the chain.
+    return array_map(function ($include) {
+      return $include->rasterizeValue();
+    }, $this->getIncludes());
+  }
+
+}
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..83e859e
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\jsonapi\Normalizer\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 getIncludes() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPropertyType() {
+    return $this->propertyType;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeIncludes() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAllIncludes() {
+    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..5701f95
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+
+/**
+ * Helps normalize relationship items in compliance with the JSON API spec.
+ *
+ * @internal
+ */
+class RelationshipItemNormalizerValue extends FieldItemNormalizerValue implements ValueExtractorInterface, CacheableDependencyInterface {
+
+  use CacheableDependenciesMergerTrait;
+
+  /**
+   * Resource path.
+   *
+   * @var string
+   */
+  protected $resource;
+
+  /**
+   * Included normalized entity, if any.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue|\Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue|\Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue|null
+   */
+  protected $include;
+
+  /**
+   * 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 string $resource
+   *   The resource type of the target entity.
+   * @param \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue|\Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue|\Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue|null $include
+   *   The included normalized entity, or NULL.
+   */
+  public function __construct(array $values, CacheableDependencyInterface $values_cacheability, $resource, $include) {
+    assert($include === NULL || $include instanceof EntityNormalizerValue || $include instanceof JsonApiDocumentTopLevelNormalizerValue || $include instanceof HttpExceptionNormalizerValue);
+    parent::__construct($values, $values_cacheability);
+    if ($include !== NULL) {
+      $this->setCacheability(static::mergeCacheableDependencies([$include, $values_cacheability]));
+    }
+    $this->resource = $resource;
+    $this->include = $include;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (!$value = parent::rasterizeValue()) {
+      return $value;
+    }
+    $rasterized_value = [
+      'type' => $this->resource->getTypeName(),
+      'id' => empty($value['target_uuid']) ? $value : $value['target_uuid'],
+    ];
+
+    if (!empty($value['meta'])) {
+      $rasterized_value['meta'] = $value['meta'];
+    }
+
+    return $rasterized_value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeIncludes() {
+    return $this->include->rasterizeValue();
+  }
+
+  /**
+   * Gets the include.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+   *   The include.
+   */
+  public function getInclude() {
+    return $this->include;
+  }
+
+}
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..2549879
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php
@@ -0,0 +1,131 @@
+<?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->resourceType->getPublicName($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() ?: [];
+    return empty($data) && $this->cardinality == 1
+      ? ['data' => NULL, 'links' => $links]
+      : ['data' => $data, 'links' => $links];
+  }
+
+  /**
+   * Gets the links for the relationship.
+   *
+   * @param string $field_name
+   *   The public field name for the relationship.
+   *
+   * @return array
+   *   An array of links to be rasterized.
+   */
+  protected function getLinks($field_name) {
+    $route_parameters = [
+      'related' => $field_name,
+    ];
+    $links['self'] = $this->linkManager->getEntityLink(
+      $this->hostEntityId,
+      $this->resourceType,
+      $route_parameters,
+      'relationship'
+    );
+    $resource_types = $this->resourceType->getRelatableResourceTypesByField($field_name);
+    if (static::hasNonInternalResourceType($resource_types)) {
+      $links['related'] = $this->linkManager->getEntityLink(
+        $this->hostEntityId,
+        $this->resourceType,
+        $route_parameters,
+        '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..814d664
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php
@@ -0,0 +1,28 @@
+<?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();
+
+  /**
+   * Get the includes.
+   *
+   * @return array[]
+   *   An array of includes keyed by entity type and id pair.
+   */
+  public function rasterizeIncludes();
+
+}
diff --git a/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
new file mode 100644
index 0000000..d721df6
--- /dev/null
+++ b/core/modules/jsonapi/src/ParamConverter/EntityUuidConverter.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\jsonapi\ParamConverter;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\ParamConverter\EntityConverter;
+use Drupal\Core\TypedData\TranslatableInterface;
+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);
+    if ($storage = $this->entityManager->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) {
+        $entity = $this->entityManager->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
+      }
+      return $entity;
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies($definition, $name, Route $route) {
+    return $route->getOption('_is_jsonapi');
+  }
+
+}
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..c7b1d89
--- /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..7e1a7a0
--- /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..14f9c0d
--- /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/Resource/EntityCollection.php b/core/modules/jsonapi/src/Resource/EntityCollection.php
new file mode 100644
index 0000000..3b11569
--- /dev/null
+++ b/core/modules/jsonapi/src/Resource/EntityCollection.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\jsonapi\Resource;
+
+/**
+ * Wrapper to normalize collections with multiple entities.
+ *
+ * @internal
+ */
+class EntityCollection implements \IteratorAggregate, \Countable {
+
+  /**
+   * Entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface[]
+   */
+  protected $entities;
+
+  /**
+   * 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[] $entities
+   *   The entities for the collection.
+   */
+  public function __construct(array $entities) {
+    $this->entities = array_filter(array_values($entities));
+  }
+
+  /**
+   * 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;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php b/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php
new file mode 100644
index 0000000..ccecd88
--- /dev/null
+++ b/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\jsonapi\Resource;
+
+/**
+ * Represents a JSON API document's "top level".
+ *
+ * @see http://jsonapi.org/format/#document-top-level
+ *
+ * @internal
+ *
+ * @todo Add the missing required members: 'error' and 'meta' or document why not.
+ * @todo Add support for the missing optional members: 'jsonapi', 'links' and 'included' or document why not.
+ */
+class JsonApiDocumentTopLevel {
+
+  /**
+   * The data to normalize.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\EntityCollection
+   */
+  protected $data;
+
+  /**
+   * Instantiates a JsonApiDocumentTopLevel object.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\EntityCollection $data
+   *   The data to normalize. It can be either a straight up entity or a
+   *   collection of entities.
+   */
+  public function __construct($data) {
+    $this->data = $data;
+  }
+
+  /**
+   * Gets the data.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\EntityCollection
+   *   The data.
+   */
+  public function getData() {
+    return $this->data;
+  }
+
+}
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..f2257dd
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceType.php
@@ -0,0 +1,256 @@
+<?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;
+
+  /**
+   * 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 $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 $field_name;
+  }
+
+  /**
+   * 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 is enabled and should be considered as part of the data
+   *   model. FALSE otherwise.
+   */
+  public function isFieldEnabled($field_name) {
+    // By default all fields are enabled.
+    return 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;
+  }
+
+  /**
+   * 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.
+   */
+  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE) {
+    $this->entityTypeId = $entity_type_id;
+    $this->bundle = $bundle;
+    $this->deserializationTargetClass = $deserialization_target_class;
+    $this->internal = $internal;
+
+    $this->typeName = sprintf('%s--%s', $this->entityTypeId, $this->bundle);
+  }
+
+  /**
+   * 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. Defaults to entity_type_id/bundle.
+   */
+  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..f1ae1cf
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
@@ -0,0 +1,257 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+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;
+
+  /**
+   * All JSON API resource types.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType[]
+   */
+  protected $all = [];
+
+  /**
+   * Class to instantiate for resource type objects.
+   *
+   * @var string
+   */
+  const RESOURCE_TYPE_CLASS = ResourceType::class;
+
+  /**
+   * 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.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_bundle_info, EntityFieldManagerInterface $entity_field_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityTypeBundleInfo = $entity_bundle_info;
+    $this->entityFieldManager = $entity_field_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPathPrefix() {
+    return 'jsonapi';
+  }
+
+  // @codingStandardsIgnoreStart
+  // @todo implement \Drupal\Core\Plugin\CachedDiscoveryClearerInterface?
+  // @todo implement \Drupal\Component\Plugin\Discovery\DiscoveryInterface?
+  public function clearCachedDefinitions() {
+    $this->all = [];
+  }
+  // @codingStandardsIgnoreEnd
+
+  /**
+   * {@inheritdoc}
+   */
+  public function all() {
+    if (!$this->all) {
+      $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions());
+      foreach ($entity_type_ids as $entity_type_id) {
+        $resource_type_class = static::RESOURCE_TYPE_CLASS;
+        $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id, $resource_type_class) {
+          $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+          return new $resource_type_class(
+            $entity_type_id,
+            $bundle,
+            $entity_type->getClass(),
+            static::shouldBeInternalResourceType($entity_type)
+          );
+        }, array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id))));
+      }
+      foreach ($this->all as $resource_type) {
+        $relatable_resource_types = $this->calculateRelatableResourceTypes($resource_type);
+        $resource_type->setRelatableResourceTypes($relatable_resource_types);
+      }
+    }
+    return $this->all;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($entity_type_id, $bundle) {
+    if (empty($entity_type_id)) {
+      throw new PreconditionFailedHttpException('Server error. The current route is malformed.');
+    }
+    foreach ($this->all() as $resource) {
+      if ($resource->getEntityTypeId() == $entity_type_id && $resource->getBundle() == $bundle) {
+        return $resource;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getByTypeName($type_name) {
+    foreach ($this->all() as $resource) {
+      if ($resource->getTypeName() == $type_name) {
+        return $resource;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Whether an entity type should be an internal resource type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to assess.
+   *
+   * @todo: remove when minimum supported core version is >= 8.5, update the
+   * caller to instead call EntityTypeInterface::isInternal().
+   *
+   * @return bool
+   *   TRUE if the entity type is internal, FALSE otherwise.
+   */
+  protected static function shouldBeInternalResourceType(EntityTypeInterface $entity_type) {
+    if (method_exists(EntityTypeInterface::class, 'isInternal')) {
+      return $entity_type->isInternal();
+    }
+    return $entity_type->id() === 'content_moderation_state';
+  }
+
+  /**
+   * 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.
+   *
+   * @return array
+   *   The relatable JSON API resource types, keyed by field name.
+   */
+  protected function calculateRelatableResourceTypes(ResourceType $resource_type) {
+    // 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()
+      );
+
+      return array_map(function ($field_definition) {
+        return $this->getRelatableResourceTypesFromFieldDefinition($field_definition);
+      }, array_filter($field_definitions, function ($field_definition) {
+        return $this->isReferenceFieldDefinition($field_definition);
+      }));
+    }
+    return [];
+  }
+
+  /**
+   * Get relatable resource types from a field definition.
+   *
+   * @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition from which to calculate relatable 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) {
+    $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) {
+      return $this->get($entity_type_id, $target_bundle);
+    }, $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..b44f3d6
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepositoryInterface.php
@@ -0,0 +1,52 @@
+<?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.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   The requested JSON API resource type, if it exists. NULL otherwise.
+   */
+  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);
+
+  /**
+   * Gets the path prefix for routes managed by JSON API.
+   *
+   * @return string
+   *   The route prefix in the JSON API route paths.
+   */
+  public function getPathPrefix();
+
+}
diff --git a/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
new file mode 100644
index 0000000..d7ca886
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\Enhancer\RouteEnhancerInterface;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Processes the request query parameters.
+ *
+ * @internal
+ */
+class JsonApiParamEnhancer implements RouteEnhancerInterface {
+
+  /**
+   * The filter normalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $filterNormalizer;
+
+  /**
+   * The sort normalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $sortNormalizer;
+
+  /**
+   * The page normalizer.
+   *
+   * @var Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $pageNormalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(DenormalizerInterface $filter_normalizer, DenormalizerInterface $sort_normalizer, DenormalizerInterface $page_normalizer) {
+    $this->filterNormalizer = $filter_normalizer;
+    $this->sortNormalizer = $sort_normalizer;
+    $this->pageNormalizer = $page_normalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Route $route) {
+    // This enhancer applies to the JSON API routes.
+    return $route->getDefault(RouteObjectInterface::CONTROLLER_NAME) == Routes::FRONT_CONTROLLER;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    $options = [];
+
+    $route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
+    $context = [
+      'entity_type_id' => $route->getRequirement('_entity_type'),
+      'bundle' => $route->getRequirement('_bundle'),
+    ];
+
+    if ($request->query->has('filter')) {
+      $filter = $request->query->get('filter');
+      $options['filter'] = $this->filterNormalizer->denormalize($filter, Filter::class, NULL, $context);
+    }
+
+    if ($request->query->has('sort')) {
+      $sort = $request->query->get('sort');
+      $options['sort'] = $this->sortNormalizer->denormalize($sort, Sort::class, NULL, $context);
+    }
+
+    $page = ($request->query->has('page')) ? $request->query->get('page') : [];
+    $options['page'] = $this->pageNormalizer->denormalize($page, OffsetPage::class);
+
+    $defaults['_json_api_params'] = $options;
+
+    return $defaults;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/RouteEnhancer.php b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
new file mode 100644
index 0000000..6bc5a54
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\EnhancerInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Ensures the loaded entity matches the requested resource type.
+ *
+ * @internal
+ */
+class RouteEnhancer implements EnhancerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Route $route) {
+    return (bool) $route->getRequirement('_bundle') && (bool) $route->getRequirement('_entity_type');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request) {
+    $route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
+    $entity_type = $route->getRequirement('_entity_type');
+    if (!isset($defaults[$entity_type]) || !($entity = $defaults[$entity_type])) {
+      return $defaults;
+    }
+    $retrieved_bundle = $entity->bundle();
+    $configured_bundle = $route->getRequirement('_bundle');
+    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..9f83f61
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Routes.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Authentication\AuthenticationCollectorInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\Resource\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 front controller for the JSON API routes.
+   *
+   * All routes will use this callback to bootstrap the JSON API process.
+   *
+   * @var string
+   */
+  const FRONT_CONTROLLER = 'jsonapi.request_handler:handle';
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The authentication collector.
+   *
+   * @var \Drupal\Core\Authentication\AuthenticationCollectorInterface
+   */
+  protected $authCollector;
+
+  /**
+   * List of providers.
+   *
+   * @var string[]
+   */
+  protected $providerIds;
+
+  /**
+   * Instantiates a Routes object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param \Drupal\Core\Authentication\AuthenticationCollectorInterface $auth_collector
+   *   The authentication provider collector.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, AuthenticationCollectorInterface $auth_collector) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->authCollector = $auth_collector;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+    $resource_type_repository = $container->get('jsonapi.resource_type.repository');
+    /* @var \Drupal\Core\Authentication\AuthenticationCollectorInterface $auth_collector */
+    $auth_collector = $container->get('authentication_collector');
+
+    return new static($resource_type_repository, $auth_collector);
+  }
+
+  /**
+   * Provides the entry point route.
+   */
+  public function entryPoint() {
+    $collection = new RouteCollection();
+
+    $path_prefix = $this->resourceTypeRepository->getPathPrefix();
+    $route_collection = (new Route('/' . $path_prefix, [
+      RouteObjectInterface::CONTROLLER_NAME => '\Drupal\jsonapi\Controller\EntryPoint::index',
+    ]))
+      ->setRequirement('_permission', 'access jsonapi resource list')
+      ->setMethods(['GET']);
+    $route_collection->addOptions([
+      '_auth' => $this->authProviderList(),
+      '_is_jsonapi' => TRUE,
+    ]);
+    $collection->add('jsonapi.resource_list', $route_collection);
+
+    return $collection;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function routes() {
+    $collection = new RouteCollection();
+    foreach ($this->resourceTypeRepository->all() as $resource_type) {
+      if ($resource_type->isInternal()) {
+        continue;
+      }
+
+      $path_prefix = $this->resourceTypeRepository->getPathPrefix();
+      $resource_path = $resource_type->getPath();
+      $route_base_path = sprintf('/%s/%s', $path_prefix, $resource_path);
+      $build_route_name = function ($key) use ($resource_type) {
+        return sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $key);
+      };
+
+      $defaults = [
+        RouteObjectInterface::CONTROLLER_NAME => static::FRONT_CONTROLLER,
+      ];
+      // Options that apply to all routes.
+      $options = [
+        '_auth' => $this->authProviderList(),
+        '_is_jsonapi' => TRUE,
+      ];
+
+      // Collection endpoint, like /jsonapi/file/photo.
+      $route_collection = (new Route($route_base_path, $defaults))
+        ->setRequirement('_entity_type', (string) $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', (string) $resource_type->getBundle())
+        ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE')
+        ->setOption('serialization_class', JsonApiDocumentTopLevel::class)
+        ->setMethods(['GET', 'POST']);
+      $route_collection->addOptions($options);
+      $collection->add($build_route_name('collection'), $route_collection);
+
+      // Individual endpoint, like /jsonapi/file/photo/123.
+      $parameters = [$resource_type->getEntityTypeId() => ['type' => 'entity:' . $resource_type->getEntityTypeId()]];
+      $route_individual = (new Route(sprintf('%s/{%s}', $route_base_path, $resource_type->getEntityTypeId())))
+        ->addDefaults($defaults)
+        ->setRequirement('_entity_type', (string) $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', (string) $resource_type->getBundle())
+        ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE')
+        ->setOption('parameters', $parameters)
+        ->setOption('_auth', $this->authProviderList())
+        ->setOption('serialization_class', JsonApiDocumentTopLevel::class)
+        ->setMethods(['GET', 'PATCH', 'DELETE']);
+      $route_individual->addOptions($options);
+      $collection->add($build_route_name('individual'), $route_individual);
+
+      // Related resource, like /jsonapi/file/photo/123/comments.
+      $route_related = (new Route(sprintf('%s/{%s}/{related}', $route_base_path, $resource_type->getEntityTypeId()), $defaults))
+        ->setRequirement('_entity_type', (string) $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', (string) $resource_type->getBundle())
+        ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE')
+        ->setOption('parameters', $parameters)
+        ->setOption('_auth', $this->authProviderList())
+        ->setMethods(['GET']);
+      $route_related->addOptions($options);
+      $collection->add($build_route_name('related'), $route_related);
+
+      // Related endpoint, like /jsonapi/file/photo/123/relationships/comments.
+      $route_relationship = (new Route(sprintf('%s/{%s}/relationships/{related}', $route_base_path, $resource_type->getEntityTypeId()), $defaults + ['_on_relationship' => TRUE]))
+        ->setRequirement('_entity_type', (string) $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', (string) $resource_type->getBundle())
+        ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE')
+        ->setOption('parameters', $parameters)
+        ->setOption('_auth', $this->authProviderList())
+        ->setOption('serialization_class', EntityReferenceFieldItemList::class)
+        ->setMethods(['GET', 'POST', 'PATCH', 'DELETE']);
+      $route_relationship->addOptions($options);
+      $collection->add($build_route_name('relationship'), $route_relationship);
+    }
+
+    return $collection;
+  }
+
+  /**
+   * Build a list of authentication provider ids.
+   *
+   * @return string[]
+   *   The list of IDs.
+   */
+  protected function authProviderList() {
+    if (isset($this->providerIds)) {
+      return $this->providerIds;
+    }
+    $this->providerIds = array_keys($this->authCollector->getSortedProviders());
+
+    return $this->providerIds;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Serializer/Serializer.php b/core/modules/jsonapi/src/Serializer/Serializer.php
new file mode 100644
index 0000000..6364f8e
--- /dev/null
+++ b/core/modules/jsonapi/src/Serializer/Serializer.php
@@ -0,0 +1,108 @@
+<?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;
+
+  /**
+   * 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) || (is_array($data) || $data instanceof \Traversable)) {
+      return parent::normalize($data, $format, $context);
+    }
+
+    return $this->fallbackNormalizer->normalize($data, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $type, $format = NULL, array $context = []) {
+    if ($this->selfSupportsDenormalization($data, $type, $format)) {
+      return parent::denormalize($data, $type, $format, $context);
+    }
+    return $this->fallbackNormalizer->denormalize($data, $type, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL) {
+    return $this->selfSupportsNormalization($data, $format) || $this->fallbackNormalizer->supportsNormalization($data, $format);
+  }
+
+  /**
+   * Checks whether this class alone supports normalization.
+   *
+   * @param mixed $data
+   *   Data to normalize.
+   * @param string $format
+   *   The format being (de-)serialized from or into.
+   *
+   * @return bool
+   *   Whether this class supports normalization for the given data.
+   */
+  private function selfSupportsNormalization($data, $format = NULL) {
+    return parent::supportsNormalization($data, $format);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return $this->selfSupportsDenormalization($data, $type, $format) || $this->fallbackNormalizer->supportsDenormalization($data, $type, $format);
+  }
+
+  /**
+   * 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.
+   *
+   * @return bool
+   *   Whether this class supports normalization for the given data and type.
+   */
+  private function selfSupportsDenormalization($data, $type, $format = NULL) {
+    return parent::supportsDenormalization($data, $type, $format);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/StackMiddleware/FormatSetter.php b/core/modules/jsonapi/src/StackMiddleware/FormatSetter.php
new file mode 100644
index 0000000..0b871aa
--- /dev/null
+++ b/core/modules/jsonapi/src/StackMiddleware/FormatSetter.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\jsonapi\StackMiddleware;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+
+/**
+ * Sets the 'api_json' format on all requests to JSON API-managed routes.
+ *
+ * @internal
+ */
+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) {
+    if (static::isJsonApiRequest($request)) {
+      $request->setRequestFormat('api_json');
+    }
+
+    return $this->httpKernel->handle($request, $type, $catch);
+  }
+
+  /**
+   * Checks whether the current request is a JSON API request.
+   *
+   * Inspects:
+   * - request path (uses a heuristic, because e.g. language negotiation may use
+   *   path prefixes)
+   * - 'Accept' request header value.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return bool
+   *   Whether the current request is a JSON API request.
+   */
+  protected static function isJsonApiRequest(Request $request) {
+    return strpos($request->getPathInfo(), '/jsonapi/') !== FALSE
+      &&
+      // Check if the 'Accept' header includes the JSON API MIME type.
+      count(array_filter($request->getAcceptableContentTypes(), function ($accept) {
+        return strpos($accept, 'application/vnd.api+json') === 0;
+      }));
+  }
+
+}
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..0b2da34
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Drupal\jsonapi_test_collection_count\ResourceType;
+
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+
+/**
+ * Provides a repository of JSON API configurable resource types.
+ */
+class CountableResourceTypeRepository extends ResourceTypeRepository {
+
+  /**
+   * {@inheritdoc}
+   */
+  const RESOURCE_TYPE_CLASS = CountableResourceType::class;
+
+}
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..b8ad331
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/jsonapi_test_data_type.services.yml
@@ -0,0 +1,6 @@
+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 }
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..abb6610
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_data_type/src/Normalizer/StringNormalizer.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\jsonapi_test_data_type\Normalizer;
+
+use Drupal\Core\TypedData\Plugin\DataType\StringData;
+use Drupal\serialization\Normalizer\NormalizerBase;
+
+/**
+ * Normalizes string data, with a twist: it replaces 'super' with 'NOT'.
+ */
+class StringNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = StringData::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    return str_replace('super', 'NOT', $object->getValue());
+  }
+
+}
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..daf20ce
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_access/jsonapi_test_field_access.module
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for testing the JSON API module.
+ */
+
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Access\AccessResult;
+
+/**
+ * Implements hook_entity_field_access().
+ */
+function jsonapi_test_field_access_entity_field_access($operation, FieldDefinitionInterface $field_definition) {
+  // @see \Drupal\Tests\jsonapi\Functional\ResourceTestBase::testGetRelationships().
+  if ($field_definition->getName() === 'field_jsonapi_test_entity_ref') {
+    // Forbid access in all cases.
+    return AccessResult::forbidden();
+  }
+
+  // No opinion.
+  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..0376ca6
--- /dev/null
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Normalizer/StringNormalizer.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\jsonapi_test_field_type\Normalizer;
+
+use Drupal\Core\Field\Plugin\Field\FieldType\StringItem;
+use Drupal\serialization\Normalizer\FieldItemNormalizer;
+
+/**
+ * Normalizes string fields, with a twist: it replaces 'super' with 'NOT'.
+ */
+class StringNormalizer extends FieldItemNormalizer {
+
+  /**
+   * {@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;
+  }
+
+}
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..cc57864
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ActionTest.php
@@ -0,0 +1,113 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'action--action',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'configuration' => [
+            'rid' => 'anonymous',
+          ],
+          'dependencies' => [
+            'config' => ['user.role.anonymous'],
+            'module' => ['user'],
+          ],
+          'id' => 'user_add_role_action.anonymous',
+          'label' => 'Add the anonymous role to the selected users',
+          'langcode' => 'en',
+          'plugin' => 'user_add_role_action',
+          'status' => TRUE,
+          'type' => 'user',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..3dffc9d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BaseFieldOverrideTest.php
@@ -0,0 +1,131 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'base_field_override--base_field_override',
+        'links' => [
+          'self' => $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',
+          'id' => 'node.camelids.promote',
+          'label' => NULL,
+          'langcode' => 'en',
+          'required' => FALSE,
+          'settings' => [
+            'on_label' => 'On',
+            'off_label' => 'Off',
+          ],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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/BlockContentTest.php b/core/modules/jsonapi/tests/src/Functional/BlockContentTest.php
new file mode 100644
index 0000000..4670478
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockContentTest.php
@@ -0,0 +1,265 @@
+<?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\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+
+/**
+ * JSON API integration test for the "BlockContent" content entity type.
+ *
+ * @group jsonapi
+ */
+class BlockContentTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block_content'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block_content';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'block_content--basic';
+
+  /**
+   * {@inheritdoc}
+   *
+   * @var \Drupal\config_test\ConfigTestInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $uniqueFieldNames = ['url'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer blocks']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function createEntity() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2835845#comment-12265016
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return;
+    }
+
+    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',
+      ],
+    ])
+      ->setPublished(FALSE);
+    $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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block_content--basic',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 1,
+          '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' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'info' => 'Llama',
+          'revision_id' => 1,
+          'revision_log' => NULL,
+          'revision_created' => (int) $this->entity->getRevisionCreationTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'revision_created' => $this->formatExpectedTimestampItemValues($this->entity->getRevisionCreationTime()), */
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'status' => FALSE,
+          'langcode' => 'en',
+          'default_langcode' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'type' => [
+            'data' => [
+              'id' => BlockContentType::load('basic')->uuid(),
+              'type' => 'block_content_type--block_content_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/type',
+              'self' => $self_url . '/relationships/type',
+            ],
+          ],
+          'revision_user' => [
+            'data' => NULL,
+            'links' => [
+              'related' => $self_url . '/revision_user',
+              'self' => $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 testGetIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2835845#comment-12265016
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('BlockContent entities were made publishable in 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testGetIndividual();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2835845#comment-12265016
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('BlockContent entities were made publishable in 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testGetIndividual();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2835845#comment-12265016
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('BlockContent entities were made publishable in 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testPatchIndividual();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDeleteIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2835845#comment-12265016
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('BlockContent entities were made publishable in 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testDeleteIndividual();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
+  }
+
+}
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..63ddeff
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockContentTypeTest.php
@@ -0,0 +1,105 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block_content_type--block_content_type',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Provides a competitive alternative to the "basic" type',
+          'id' => 'pascal',
+          'label' => 'Pascal',
+          'langcode' => 'en',
+          'revision' => 0,
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..f978aaa
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/BlockTest.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\block\Entity\Block;
+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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block--block',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 'llama',
+          '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' => [],
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update once https://www.drupal.org/node/2300677 is fixed.
+  }
+
+  /**
+   * {@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 '';
+
+      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']);
+  }
+
+}
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..b14368a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/CommentTest.php
@@ -0,0 +1,415 @@
+<?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\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+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 BcTimestampNormalizerUnixTestTrait;
+  use CommentTestTrait;
+
+  /**
+   * {@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(TRUE)
+      ->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());
+    $document = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'comment--comment',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'cid' => 1,
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'created' => $this->formatExpectedTimestampItemValues(123456789), */
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          '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/',
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'comment_type' => [
+            'data' => [
+              'id' => CommentType::load('comment')->uuid(),
+              'type' => 'comment_type--comment_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/comment_type',
+              'self' => $self_url . '/relationships/comment_type',
+            ],
+          ],
+          'entity_id' => [
+            'data' => [
+              'id' => EntityTest::load(1)->uuid(),
+              'type' => 'entity_test--bar',
+            ],
+            'links' => [
+              'related' => $self_url . '/entity_id',
+              'self' => $self_url . '/relationships/entity_id',
+            ],
+          ],
+          'pid' => [
+            'data' => NULL,
+            'links' => [
+              'related' => $self_url . '/pid',
+              'self' => $self_url . '/relationships/pid',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      unset($document['data']['attributes']['comment_body']['processed']);
+    }
+    return $document;
+  }
+
+  /**
+   * {@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) {
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return parent::getExpectedCacheTags($sparse_fieldset);
+    }
+
+    $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) {
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return parent::getExpectedCacheContexts($sparse_fieldset);
+    }
+    $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.";
+
+      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() {
+    // @codingStandardsIgnoreStart
+    $this->setUpAuthorization('POST');
+
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName));
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = '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);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'The "" entity type does not exist.', $response);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'entity_type: This value should not be null.', $response);
+
+    // 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 favor of the two commented lines in
+    // https://www.drupal.org/node/2820364.
+    try {
+      $response = $this->request('POST', $url, $request_options);
+      // This happens on DrupalCI.
+      $this->assertSame(500, $response->getStatusCode());
+    }
+    catch (\Exception $e) {
+      // This happens on local development environments
+      $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
+    }
+    // $response = $this->request('POST', $url, $request_options);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'entity_id: This value should not be null.', $response);
+
+    // 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);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'Field  is unknown.', $response);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'field_name: This value should not be null.', $response);
+    // @codingStandardsIgnoreEnd
+  }
+
+  /**
+   * 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 = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument());
+
+    $url = Url::fromRoute('jsonapi.comment--comment.collection');
+
+    // 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 function entityFieldAccess(EntityInterface $entity, $field_name, $operation) {
+    // 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::entityFieldAccess($entity, $field_name, $operation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Remove this in https://www.drupal.org/project/jsonapi/issues/2940339');
+  }
+
+}
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..33c9310
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/CommentTypeTest.php
@@ -0,0 +1,105 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'comment_type--comment_type',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+          'id' => 'camelids',
+          'label' => 'Camelids',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'target_entity_type_id' => 'node',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..746831d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ConfigTestTest.php
@@ -0,0 +1,104 @@
+<?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 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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'config_test--config_test',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'id' => 'llama',
+          'weight' => 0,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'label' => 'Llama',
+          'style' => NULL,
+          'size' => NULL,
+          'size_value' => NULL,
+          'protected_property' => NULL,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..fe538f1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ConfigurableLanguageTest.php
@@ -0,0 +1,133 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'configurable_language--configurable_language',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'direction' => 'ltr',
+          'id' => 'll',
+          'label' => 'Llama Language',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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', ['configurable_language' => 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..ca9bf99
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ContactFormTest.php
@@ -0,0 +1,120 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'contact_form--contact_form',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'id' => 'llama',
+          '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,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..3e01f2b
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ContentLanguageSettingsTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Cache\Cache;
+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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'language_content_settings--language_content_settings',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'default_langcode' => 'site_default',
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+          ],
+          'id' => 'node.camelids',
+          'langcode' => 'en',
+          'language_alterable' => FALSE,
+          'status' => TRUE,
+          'target_bundle' => 'camelids',
+          'target_entity_type_id' => 'node',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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']);
+  }
+
+}
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..1b94ebe
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/DateFormatTest.php
@@ -0,0 +1,104 @@
+<?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}
+   *
+   * @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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'date_format--date_format',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'id' => 'llama',
+          'label' => 'Llama',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'pattern' => 'F d, Y',
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..d34966f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EditorTest.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'editor--editor',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [
+            'config' => [
+              'filter.format.llama',
+            ],
+            'module' => [
+              'ckeditor',
+            ],
+          ],
+          'editor' => 'ckeditor',
+          'format' => 'llama',
+          '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,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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.";
+  }
+
+}
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..e896c3d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityFormDisplayTest.php
@@ -0,0 +1,192 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_form_display--entity_form_display',
+        'links' => [
+          'self' => $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' => [],
+          'id' => 'node.camelids.default',
+          'langcode' => 'en',
+          'mode' => 'default',
+          'status' => NULL,
+          'targetEntityType' => 'node',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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}
+   */
+  public function testGetIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2866666
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('EntityFormDisplay entities had a dysfunctional access control handler until 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testGetIndividual();
+  }
+
+}
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..6635971
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityFormModeTest.php
@@ -0,0 +1,107 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_form_mode--entity_form_mode',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'cache' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'user',
+            ],
+          ],
+          'id' => 'user.test',
+          'label' => 'Test',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'user',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
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..8398821
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityTestTest.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+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);
+    $normalization = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_test--entity_test',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 1,
+          'created' => (int) $this->entity->get('created')->value,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'created' => $this->formatExpectedTimestampItemValues((int) $this->entity->get('created')->value), */
+          'field_test_text' => NULL,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'type' => 'entity_test',
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'user_id' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/user_id',
+              'self' => $self_url . '/relationships/user_id',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      unset($normalization['data']['attributes']['internal_string_field']);
+    }
+    return $normalization;
+  }
+
+  /**
+   * {@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);
+    }
+  }
+
+}
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..fc522f4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityViewDisplayTest.php
@@ -0,0 +1,145 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_view_display--entity_view_display',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'bundle' => 'camelids',
+          'content' => [
+            'links' => [
+              'region' => 'content',
+              'weight' => 100,
+            ],
+          ],
+          'dependencies' => [
+            'config' => [
+              'node.type.camelids',
+            ],
+            'module' => [
+              'user',
+            ],
+          ],
+          'hidden' => [],
+          'id' => 'node.camelids.default',
+          'langcode' => 'en',
+          'mode' => 'default',
+          'status' => TRUE,
+          'targetEntityType' => 'node',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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}
+   */
+  public function testGetIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2866666
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('EntityViewisplay entities had a dysfunctional access control handler until 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testGetIndividual();
+  }
+
+}
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..2b90618
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/EntityViewModeTest.php
@@ -0,0 +1,107 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_view_mode--entity_view_mode',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'cache' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'user',
+            ],
+          ],
+          'id' => 'user.test',
+          'label' => 'Test',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'user',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..8f4f21a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ExternalNormalizersTest.php
@@ -0,0 +1,149 @@
+<?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;
+
+/**
+ * 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 the test entities.
+    Role::load(RoleInterface::ANONYMOUS_ID)
+      ->grantPermission('view test entity')
+      ->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)
+   *
+   * @dataProvider providerTestFormatAgnosticNormalizers
+   */
+  public function testFormatAgnosticNormalizers($test_module, $expected_value_jsonapi_normalization) {
+    assert(in_array($expected_value_jsonapi_normalization, [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']);
+
+    // 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 that this does NOT affect the 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_test' => $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']);
+  }
+
+  /**
+   * 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' => [
+        // \Drupal\jsonapi_test_field_type\Normalizer\StringNormalizer::normalize()
+        'jsonapi_test_field_type',
+        static::VALUE_ORIGINAL,
+      ],
+      'Format-agnostic @DataType-level normalizers SHOULD be able to affect the JSON API normalization' => [
+        // \Drupal\jsonapi_test_data_type\Normalizer\StringNormalizer::normalize()
+        'jsonapi_test_data_type',
+        static::VALUE_OVERRIDDEN,
+      ],
+    ];
+  }
+
+}
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..9df2a72
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FeedTest.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\aggregator\Entity\Feed;
+use Drupal\Core\Url;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+
+/**
+ * JSON API integration test for the "Feed" content entity type.
+ *
+ * @group jsonapi
+ */
+class FeedTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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 getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/aggregator_feed/aggregator_feed/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'aggregator_feed--aggregator_feed',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'fid' => 1,
+          'url' => 'http://example.com/rss.xml',
+          'title' => 'Feed',
+          'refresh' => 900,
+          'checked' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'checked' => $this->formatExpectedTimestampItemValues(123456789), */
+          'queued' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'queued' => $this->formatExpectedTimestampItemValues(123456789), */
+          'link' => 'http://example.com',
+          'description' => 'Feed Resource Test 1',
+          'image' => 'http://example.com/feed_logo',
+          'hash' => 'abcdefg',
+          'etag' => 'hijklmn',
+          'modified' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'modified' => $this->formatExpectedTimestampItemValues(123456789), */
+          'langcode' => 'en',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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.";
+    }
+  }
+
+}
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..f28d8b1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FieldConfigTest.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'field_config--field_config',
+        'links' => [
+          'self' => $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',
+          'id' => 'node.camelids.field_llama',
+          'label' => 'field_llama',
+          'langcode' => 'en',
+          'required' => FALSE,
+          'settings' => [],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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/FieldStorageConfigTest.php b/core/modules/jsonapi/tests/src/Functional/FieldStorageConfigTest.php
new file mode 100644
index 0000000..13e7631
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FieldStorageConfigTest.php
@@ -0,0 +1,120 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'field_storage_config--field_storage_config',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'cardinality' => 1,
+          'custom_storage' => FALSE,
+          'dependencies' => [
+            'module' => [
+              'node',
+            ],
+          ],
+          'entity_type' => 'node',
+          'field_name' => 'true_llama',
+          'id' => 'node.true_llama',
+          'indexes' => [],
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'module' => 'core',
+          'persist_with_no_fields' => FALSE,
+          'settings' => [],
+          'status' => TRUE,
+          'translatable' => TRUE,
+          'type' => 'boolean',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..4d6af09
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FileTest.php
@@ -0,0 +1,218 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\file\Entity\File;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * JSON API integration test for the "File" content entity type.
+ *
+ * @group jsonapi
+ */
+class FileTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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 getExpectedDocument() {
+    $self_url = Url::fromUri('base:/jsonapi/file/file/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $normalization = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'file--file',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'created' => (int) $this->entity->getCreatedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'created' => $this->formatExpectedTimestampItemValues((int) $this->entity->getCreatedTime()), */
+          'fid' => 1,
+          'filemime' => 'text/plain',
+          'filename' => 'drupal.txt',
+          'filesize' => (int) $this->entity->getSize(),
+          'langcode' => 'en',
+          'status' => TRUE,
+          // @todo Decide what to do with this in https://www.drupal.org/project/jsonapi/issues/2926463
+          'url' => base_path() . $this->siteDirectory . '/files/drupal.txt',
+          'uri' => [
+            'url' => base_path() . $this->siteDirectory . '/files/drupal.txt',
+            'value' => 'public://drupal.txt',
+          ],
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $this->author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $normalization['data']['attributes']['uri'] = $normalization['data']['attributes']['uri']['url'];
+    }
+    return $normalization;
+
+  }
+
+  /**
+   * {@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.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetIndividual() {
+    // @todo Remove when JSON API requires Drupal 8.5 or newer.
+    // @see https://www.drupal.org/project/drupal/issues/2866666
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('File entities had a dysfunctional access control handler until 8.5, this is necessary for this test coverage to work.');
+    }
+    return parent::testGetIndividual();
+  }
+
+}
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..629317e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/FilterFormatTest.php
@@ -0,0 +1,121 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'filter_format--filter_format',
+        'links' => [
+          'self' => $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,
+              ],
+            ],
+          ],
+          'format' => 'pablo',
+          'langcode' => 'es',
+          'name' => 'Pablo Piccasso',
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..80ce98a
--- /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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'image_style--image_style',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'effects' => [
+            $this->effectUuid => [
+              'uuid' => $this->effectUuid,
+              'id' => 'image_scale_and_crop',
+              'weight' => 0,
+              'data' => [
+                'width' => 120,
+                'height' => 121,
+              ],
+            ],
+          ],
+          'label' => 'Camelids',
+          'langcode' => 'en',
+          'name' => 'camelids',
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..4029ed5
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/InternalEntitiesTest.php
@@ -0,0 +1,203 @@
+<?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\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * 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([
+      'access jsonapi resource list',
+      '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() {
+    $this->skipIfIsInternalIsNotSupported();
+    $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->skipIfIsInternalIsNotSupported();
+    // This cannot be in a data provider because it needs values created by the
+    // setUp method.
+    $paths = [
+      'individual' => $this->getIndividual($this->internalEntity),
+      'collection' => $this->jsonapiGet("/jsonapi/{$this->internalEntity->getEntityTypeId()}/{$this->internalEntity->bundle()}"),
+      'related' => $this->getRelated($this->referencingEntity, 'field_internal'),
+    ];
+    foreach ($paths as $type => $document) {
+      $this->assertSame(
+        404,
+        $document['errors'][0]['status'],
+        "The '{$type}' route should not be available for internal resource types.'"
+      );
+    }
+  }
+
+  /**
+   * Asserts that internal entities are not included in compound documents.
+   */
+  public function testIncludes() {
+    $this->skipIfIsInternalIsNotSupported();
+    $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() {
+    $this->skipIfIsInternalIsNotSupported();
+    $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);
+  }
+
+  /**
+   * Only run tests when Drupal version is >= 8.5.
+   */
+  protected function skipIfIsInternalIsNotSupported() {
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      $this->markTestSkipped('The Drupal Core version must be >= 8.5');
+    }
+  }
+
+}
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..b643228
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ItemTest.php
@@ -0,0 +1,172 @@
+<?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() {
+    $entity = $this->entity->createDuplicate();
+    $entity->setLink('https://www.example.org/');
+    $label_key = $entity->getEntityType()->getKey('label');
+    if ($label_key) {
+      $entity->set($label_key, $entity->label() . '_dupe');
+    }
+    $entity->save();
+    return $entity;
+  }
+
+  /**
+   * {@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 testRelated() {
+    $this->markTestSkipped('Remove this override in https://www.drupal.org/project/drupal/issues/2149851');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetRelationships() {
+    $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..eaa215d
--- /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);
+
+    // 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..81e9d41
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
@@ -0,0 +1,839 @@
+<?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);
+    // 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']);
+    // 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']);
+    // 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.
+    $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->assertEquals('/node--article/' . $this->nodes[60]->uuid(), $single_output['errors'][0]['id']);
+
+    // 6. Single relationship item.
+    $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/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([], $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->assertArrayHasKey('tid', $single_output['data'][0]['attributes']);
+    $this->assertContains(
+      '/taxonomy_term/tags/',
+      $single_output['data'][0]['links']['self']
+    );
+    $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($single_output['meta']['errors']));
+    $this->assertEquals(403, $single_output['meta']['errors'][0]['status']);
+    $this->assertEquals('/node--article/' . $this->nodes[1]->uuid(), $single_output['meta']['errors'][0]['id']);
+    $this->assertFalse(empty($single_output['meta']['errors'][0]['source']['pointer']));
+    $this->nodes[1]->set('status', TRUE);
+    $this->nodes[1]->save();
+    // 13. Test filtering when using short syntax.
+    $filter = [
+      'uid.uuid' => ['value' => $this->user->uuid()],
+      'field_tags.uuid' => ['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.uuid',
+          'value' => $this->user->uuid(),
+          'memberOf' => 'and_group',
+        ],
+      ],
+      'filter_tags' => [
+        'condition' => [
+          'path' => 'field_tags.uuid',
+          '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.uuid',
+          '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.uuid',
+          'value' => $this->tags[0]->uuid(),
+          'memberOf' => 'or_group',
+        ],
+      ],
+      'filter_tags_2' => [
+        'condition' => [
+          'path' => 'field_tags.uuid',
+          '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', [
+      'user' => $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);
+    // Without the 'Accept' header we cannot know we want the 404 error
+    // formatted as JSON API.
+    $this->assertSession()->responseHeaderContains('Content-Type', 'text/html');
+    // 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');
+    // 21. Test the value of the computed 'url' field.
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/file/file'));
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    $expected_url = (floatval(\Drupal::VERSION) < 8.5)
+      ? $collection_output['data'][0]['attributes']['uri']
+      : $collection_output['data'][0]['attributes']['uri']['value'];
+    $this->assertEquals($collection_output['data'][0]['attributes']['url'], $expected_url);
+    // 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_nids = array_map(function ($result) {
+      return $result['attributes']['nid'];
+    }, $output['data']);
+    $this->assertCount(6, $output_nids);
+    $this->assertEquals([5, 4, 3, 2, 1, 10], $output_nids);
+    // 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_nids = array_map(function ($result) {
+      return $result['attributes']['nid'];
+    }, $output['data']);
+    $this->assertCount(6, $output_nids);
+    $this->assertEquals([1, 2, 3, 4, 5, 6], $output_nids);
+    // 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_nids = array_map(function ($result) {
+      return $result['attributes']['nid'];
+    }, $output['data']);
+    $this->assertCount(5, $output_nids);
+    $this->assertCount(1, $output['meta']['errors']);
+    $this->assertEquals([60, 59, 58, 57, 56], $output_nids);
+    // 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_nids = array_map(function ($result) {
+      return $result['attributes']['nid'];
+    }, $output['data']);
+    $this->assertCount(5, $output_nids);
+    $this->assertCount(1, $output['meta']['errors']);
+    $this->assertEquals([56, 57, 58, 59, 60], $output_nids);
+    // 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 POST, PATCH and DELETE.
+   */
+  public function testWrite() {
+    $this->createDefaultContent(0, 3, FALSE, FALSE, static::IS_NOT_MULTILINGUAL);
+    // 1. Successful post.
+    $collection_url = Url::fromRoute('jsonapi.node--article.collection');
+    $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' => [
+          'type' => [
+            'data' => [
+              'type' => 'node_type--node_type',
+              'id' => 'article',
+            ],
+          ],
+          '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->assertArrayHasKey('uuid', $created_response['data']['attributes']);
+    $uuid = $created_response['data']['attributes']['uuid'];
+    $this->assertEquals(2, count($created_response['data']['relationships']['field_tags']['data']));
+    $this->assertEquals($created_response['data']['links']['self'], $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(403, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Forbidden', $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']);
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands, and make more strict.
+    /*
+     * // 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(422, $response->getStatusCode());
+     * $this->assertNotEmpty($created_response['errors']);
+     * $this->assertEquals(
+     *   'Unprocessable Entity',
+     *   $created_response['errors'][0]['title']
+     * );
+     */
+
+    // 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(201, $response->getStatusCode());
+    $this->assertEquals(0, count($created_response['data']['relationships']['field_tags']['data']));
+    // 6. Serialization 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(422, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Unprocessable Entity', $created_response['errors'][0]['title']);
+    // 6.1 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', [
+      'node' => $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', [
+      'node' => $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.relationship', [
+      'node' => $uuid,
+      'related' => 'field_tags',
+    ]);
+    $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(201, $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'],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(200, $response->getStatusCode());
+    $this->assertCount(1, $updated_response['data']);
+    $this->assertEquals('taxonomy_term--tags', $updated_response['data'][0]['type']);
+    $this->assertEquals($this->tags[1]->uuid(), $updated_response['data'][0]['id']);
+    // 11. Successful DELETE to related endpoint.
+    $payload = $updated_response;
+    $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($payload),
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $this->assertEquals(403, $response->getStatusCode());
+    $response = $this->request('DELETE', $relationship_url, [
+      // Remove the existing relationship item.
+      'body' => Json::encode($payload),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+      'headers' => ['Content-Type' => 'application/vnd.api+json'],
+    ]);
+    $updated_response = Json::decode($response->getBody()->__toString());
+    $this->assertEquals(201, $response->getStatusCode());
+    $this->assertCount(0, $updated_response['data']);
+    // 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(400, $response->getStatusCode());
+    $this->assertEquals("The provided field (field_that_doesnt_exist) does not exist in the entity with ID $uuid.",
+      $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..f683a22
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTestBase.php
@@ -0,0 +1,317 @@
+<?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\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\file\Entity\File;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\BrowserTestBase;
+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.
+   */
+  protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link, $is_multilingual) {
+    $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++) {
+      // 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 = [
+        'uid' => ['target_id' => $this->user->id()],
+        'type' => 'article',
+        '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/MediaTest.php b/core/modules/jsonapi/tests/src/Functional/MediaTest.php
new file mode 100644
index 0000000..41f2a15
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MediaTest.php
@@ -0,0 +1,332 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\file\Entity\File;
+use Drupal\media\Entity\Media;
+use Drupal\media\Entity\MediaType;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * JSON API integration test for the "Media" content entity type.
+ *
+ * @group jsonapi
+ */
+class MediaTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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':
+        // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+        if (floatval(\Drupal::VERSION) < 8.5) {
+          $this->grantPermissionsToTestedRole(['create media']);
+        }
+        $this->grantPermissionsToTestedRole(['create camelids media']);
+        break;
+
+      case 'PATCH':
+        // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+        if (floatval(\Drupal::VERSION) < 8.5) {
+          $this->grantPermissionsToTestedRole(['update any media']);
+        }
+        $this->grantPermissionsToTestedRole(['edit any camelids media']);
+        // @todo Remove this in https://www.drupal.org/node/2824851.
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'DELETE':
+        // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+        if (floatval(\Drupal::VERSION) < 8.5) {
+          $this->grantPermissionsToTestedRole(['delete any media']);
+        }
+        $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();
+
+    // Create a "Llama" media item.
+    // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+    $file_field_name = floatval(\Drupal::VERSION) >= 8.5 ? 'field_media_file' : 'field_media_file_1';
+    $media = Media::create([
+      'bundle' => 'camelids',
+      $file_field_name => [
+        'target_id' => $file->id(),
+      ],
+    ]);
+    $media
+      ->setName('Llama')
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setOwnerId($this->account->id())
+      ->setRevisionUserId($this->account->id())
+      ->save();
+
+    return $media;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedDocument() {
+    $file = File::load(1);
+    $thumbnail = File::load(2);
+    $author = User::load($this->entity->getOwnerId());
+    $self_url = Url::fromUri('base:/jsonapi/media/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $normalization = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'media--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'mid' => 1,
+          'vid' => 1,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'status' => TRUE,
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'created' => $this->formatExpectedTimestampItemValues(123456789), */
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'revision_created' => (int) $this->entity->getRevisionCreationTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'revision_created' => $this->formatExpectedTimestampItemValues((int) $this->entity->getRevisionCreationTime()), */
+          '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,
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'field_media_file' => [
+            'data' => [
+              'id' => $file->uuid(),
+              'meta' => [
+                'description' => NULL,
+                'display' => NULL,
+              ],
+              'type' => 'file--file',
+            ],
+            'links' => [
+              'related' => $self_url . '/field_media_file',
+              'self' => $self_url . '/relationships/field_media_file',
+            ],
+          ],
+          'thumbnail' => [
+            'data' => [
+              'id' => $thumbnail->uuid(),
+              'meta' => [
+                'alt' => 'Thumbnail',
+                'width' => '180',
+                'height' => '180',
+                'title' => 'Llama',
+              ],
+              'type' => 'file--file',
+            ],
+            'links' => [
+              'related' => $self_url . '/thumbnail',
+              'self' => $self_url . '/relationships/thumbnail',
+            ],
+          ],
+          'bundle' => [
+            'data' => [
+              'id' => MediaType::load('camelids')->uuid(),
+              'type' => 'media_type--media_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/bundle',
+              'self' => $self_url . '/relationships/bundle',
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'revision_user' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/revision_user',
+              'self' => $self_url . '/relationships/revision_user',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      unset($normalization['data']['attributes']['revision_default']);
+      $normalization['data']['relationships']['field_media_file_1'] = $normalization['data']['relationships']['field_media_file'];
+      $normalization['data']['relationships']['field_media_file_1']['links']['related'] .= '_1';
+      $normalization['data']['relationships']['field_media_file_1']['links']['self'] .= '_1';
+      unset($normalization['data']['relationships']['field_media_file']);
+    }
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    return [
+      'data' => [
+        'type' => 'media--camelids',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET';
+        return "The 'view media' permission is required and the media item must be published.";
+
+      default:
+        return '';
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\media\MediaAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['media:1']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPostIndividual() {
+    $this->markTestSkipped('POSTing File Media items is not supported until https://www.drupal.org/node/1927648 is solved.');
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo Determine if this override should be removed in https://www.drupal.org/project/jsonapi/issues/2952522
+   */
+  protected function getExpectedGetRelationshipDocumentData($relationship_field_name) {
+    $data = parent::getExpectedGetRelationshipDocumentData($relationship_field_name);
+    switch ($relationship_field_name) {
+      case 'thumbnail':
+        $data['meta'] = [
+          'alt' => 'Thumbnail',
+          'width' => '180',
+          'height' => '180',
+          'title' => 'Llama',
+        ];
+        return $data;
+
+      case 'field_media_file':
+        $data['meta'] = [
+          'description' => NULL,
+          'display' => NULL,
+        ];
+        return $data;
+
+      default:
+        return $data;
+    }
+  }
+
+}
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..6da5a35
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MediaTypeTest.php
@@ -0,0 +1,111 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'media_type--media_type',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.',
+          'field_map' => [],
+          'id' => 'camelids',
+          'label' => NULL,
+          'langcode' => 'en',
+          'new_revision' => FALSE,
+          'queue_thumbnail_downloads' => FALSE,
+          'source' => 'file',
+          'source_configuration' => [
+            'source_field' => '',
+          ],
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..cb258fc
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MenuLinkContentTest.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\menu_link_content\Entity\MenuLinkContent;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+
+/**
+ * JSON API integration test for the "MenuLinkContent" content entity type.
+ *
+ * @group jsonapi
+ */
+class MenuLinkContentTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'menu_link_content--menu_link_content',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'bundle' => 'menu_link_content',
+          'id' => 1,
+          'link' => [
+            'uri' => 'https://nl.wikipedia.org/wiki/Llama',
+            'title' => NULL,
+            'options' => [],
+          ],
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'default_langcode' => TRUE,
+          'description' => 'Llama Gabilondo',
+          'enabled' => TRUE,
+          'expanded' => FALSE,
+          'external' => FALSE,
+          'langcode' => 'en',
+          'menu_name' => 'main',
+          'parent' => NULL,
+          'rediscover' => FALSE,
+          'title' => 'Llama Gabilondo',
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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 '';
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+}
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..ea7e8c2
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MenuTest.php
@@ -0,0 +1,102 @@
+<?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}
+   *
+   * @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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'menu--menu',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'description' => 'Menu',
+          'id' => 'menu',
+          'label' => 'Menu',
+          'langcode' => 'en',
+          'locked' => FALSE,
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..279e433
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/MessageTest.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\contact\Entity\ContactForm;
+use Drupal\contact\Entity\Message;
+
+/**
+ * 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() {
+    $this->markTestSkipped('Change this override in https://www.drupal.org/project/jsonapi/issues/2944977');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchIndividual() {
+    $this->markTestSkipped('Change this override in https://www.drupal.org/project/jsonapi/issues/2944977');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testDeleteIndividual() {
+    $this->markTestSkipped('Change this override in https://www.drupal.org/project/jsonapi/issues/2944977');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testRelated() {
+    $this->markTestSkipped('Change this override in https://www.drupal.org/project/jsonapi/issues/2944977');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testGetRelationships() {
+    $this->markTestSkipped('Change this override in https://www.drupal.org/project/jsonapi/issues/2944977');
+  }
+
+}
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..fb611c5
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -0,0 +1,343 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+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\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+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 BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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(TRUE)
+      ->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();
+    $normalization = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'created' => $this->formatExpectedTimestampItemValues(123456789), */
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'nid' => 1,
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'promote' => TRUE,
+          'revision_log' => NULL,
+          'revision_timestamp' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'revision_timestamp' => $this->formatExpectedTimestampItemValues(123456789), */
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'status' => TRUE,
+          'sticky' => FALSE,
+          'title' => 'Llama',
+          'uuid' => $this->entity->uuid(),
+          'vid' => 1,
+        ],
+        'relationships' => [
+          'type' => [
+            'data' => [
+              'id' => NodeType::load('camelids')->uuid(),
+              'type' => 'node_type--node_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/type',
+              'self' => $self_url . '/relationships/type',
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'revision_uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/revision_uid',
+              'self' => $self_url . '/relationships/revision_uid',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this modification when JSON API requires Drupal 8.5 or newer, and do an early return above instead.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      unset($normalization['data']['attributes']['revision_default']);
+    }
+    return $normalization;
+  }
+
+  /**
+   * {@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), [static::$entityTypeId => $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::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 = [
+      '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' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/node--camelids/' . $this->entity->uuid(),
+          '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), [static::$entityTypeId => $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 = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to GET the selected resource.',
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/node--camelids/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $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');
+  }
+
+}
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..474d0bc
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTypeTest.php
@@ -0,0 +1,114 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node_type--node_type',
+        'links' => [
+          'self' => $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,
+          'type' => 'camelids',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..ddb9f96
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RdfMappingTest.php
@@ -0,0 +1,149 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'rdf_mapping--rdf_mapping',
+        'links' => [
+          'self' => $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',
+              ],
+            ],
+          ],
+          'id' => 'node.camelids',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'targetEntityType' => 'node',
+          'types' => [
+            'sioc:Item',
+            'foaf:Document',
+          ],
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..dd40d9c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -0,0 +1,392 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+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 array $responses
+   *   An array or ResourceResponses to be merged.
+   * @param string $self_link
+   *   The self link for the merged document.
+   * @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 (!empty($response_document['errors'])) {
+        // If any of the response documents had top-level errors, we should
+        // later expect the document to have 'meta' errors too.
+        foreach ($response_document['errors'] as $error) {
+          if ($is_multiple) {
+            unset($error['source']['pointer']);
+            $merged_document['meta']['errors'][] = $error;
+          }
+          else {
+            $merged_document['errors'][] = $error;
+          }
+        }
+      }
+      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());
+    }
+    // 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 {
+      $merged_document['links'] = ['self' => $self_link];
+      // @todo Assign this to every document, even with errors in https://www.drupal.org/project/jsonapi/issues/2949807
+      $merged_document['jsonapi'] = [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ];
+    }
+    // If any successful code exists, use that one. Partial success isn't
+    // defined by HTTP semantics. When different response codes exist, fall
+    // back to a more general code. Any one success will make the merged request
+    // a success.
+    $merged_response_code = array_reduce($responses, function ($merged_response_code, $response) {
+      $response_code = $response->getStatusCode();
+      assert($response_code >= 200 && $response_code < 500, 'Responses must be valid and complete to be merged.');
+      assert(!($response_code >= 300 && $response_code < 400), 'Redirect responses cannot be merged.');
+      // In the initial case, use the first response code.
+      if (is_null($merged_response_code)) {
+        return $response_code;
+      }
+      // If the codes match, keep them.
+      elseif ($merged_response_code === $response_code) {
+        return $merged_response_code;
+      }
+      // If the current code or the prior code is successful, use a general 200.
+      elseif (($response_code >= 200 && $response_code < 300) || ($merged_response_code >= 200 && $merged_response_code < 300)) {
+        return 200;
+      }
+      // There are different client errors, return a general 400.
+      else {
+        return 400;
+      }
+    }, NULL);
+    return (new ResourceResponse($merged_document, $merged_response_code))->addCacheableDependency($merged_cacheability);
+  }
+
+  /**
+   * Maps an array of PSR responses to JSON API ResourceResponses.
+   *
+   * @param \Psr\Http\Message\ResponseInterface[] $responses
+   *   The PSR responses to be mapped.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse[]
+   *   The ResourceResponses.
+   */
+  protected static function toResourceResponses(array $responses) {
+    return array_map([self::class, 'toResourceResponse'], $responses);
+  }
+
+  /**
+   * Maps a response object to a JSON API ResourceResponse.
+   *
+   * This helper can be used to ease comparing, recording and merging
+   * cacheable responses and to have easier access to the JSON API document as
+   * an array instead of a string.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   A PSR response to be mapped.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The ResourceResponse.
+   */
+  protected static function toResourceResponse(ResponseInterface $response) {
+    $cacheability = new CacheableMetadata();
+    if ($cache_tags = $response->getHeader('X-Drupal-Cache-Tags')) {
+      $cacheability->addCacheTags(explode(' ', $cache_tags[0]));
+    }
+    if ($cache_contexts = $response->getHeader('X-Drupal-Cache-Contexts')) {
+      $cacheability->addCacheContexts(explode(' ', $cache_contexts[0]));
+    }
+    if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) {
+      $cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE') ? 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";
+      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'];
+    $entity_type_id = explode('--', $resource_type)[0];
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', $resource_type), [$entity_type_id => $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.
+   *
+   * @return array
+   *   The related responses, keyed by relationship field names.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getRelatedResponses(array $relationship_field_names, array $request_options) {
+    $links = array_map(function ($relationship_field_name) {
+      return static::getRelatedLink(static::toResourceIdentifier($this->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;
+    }, []);
+  }
+
+}
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..69754db
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,2194 @@
+<?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\TypedData\DataReferenceTargetDefinition;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\path\Plugin\Field\FieldType\PathItem;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+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 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;
+
+  /**
+   * 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;
+
+  /**
+   * The Entity-to-JSON-API service.
+   *
+   * @var \Drupal\jsonapi\EntityToJsonApi
+   */
+  protected $entityToJsonApi;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('jsonapi.serializer_do_not_use_removal_imminent');
+    $this->entityToJsonApi = $this->container->get('jsonapi.entity.to_jsonapi');
+
+    // Ensure the anonymous user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
+
+    // Ensure the authenticated user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
+
+    // Create an account, which tests will use. Also ensure the @current_user
+    // service uses this account, to ensure the @jsonapi.entity.to_jsonapi
+    // service that we use to generate expectations matching that of this user.
+    $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->createEntity();
+
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Add access-protected field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'type' => 'text',
+      ])
+        ->setCardinality(1)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      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' => $this->entity->bundle(),
+      ])
+        ->setTranslatable(FALSE)
+        ->setSetting('handler', 'default')
+        ->setSetting('handler_settings', [
+          'target_bundles' => [$this->account->bundle() => $this->account->bundle()],
+        ])
+        ->save();
+
+      // @todo Do this unconditionally when JSON API requires Drupal 8.5 or newer.
+      if (floatval(\Drupal::VERSION) >= 8.5) {
+        // Add multi-value field.
+        FieldStorageConfig::create([
+          'entity_type' => static::$entityTypeId,
+          'field_name' => 'field_rest_test_multivalue',
+          'type' => 'string',
+        ])
+          ->setCardinality(3)
+          ->save();
+        FieldConfig::create([
+          'entity_type' => static::$entityTypeId,
+          'field_name' => 'field_rest_test_multivalue',
+          'bundle' => $this->entity->bundle(),
+        ])
+          ->setLabel('Test field: multi-value')
+          ->setTranslatable(FALSE)
+          ->save();
+      }
+
+      \Drupal::service('jsonapi.resource_type.repository')->clearCachedDefinitions();
+      \Drupal::service('router.builder')->rebuild();
+
+      // Reload entity so that it has the new field.
+      $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+      // Some entity types are not stored, hence they cannot be reloaded.
+      if ($reloaded_entity !== NULL) {
+        $this->entity = $reloaded_entity;
+
+        // Set a default value on the fields.
+        $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+        $this->entity->set('field_jsonapi_test_entity_ref', ['user' => $this->account->id()]);
+        // @todo Do this unconditionally when JSON API requires Drupal 8.5 or newer.
+        if (floatval(\Drupal::VERSION) >= 8.5) {
+          $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
+        }
+        $this->entity->save();
+      }
+    }
+  }
+
+  /**
+   * Creates the entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity to be tested.
+   */
+  abstract protected function createEntity();
+
+  /**
+   * Creates another entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   Another entity based on $this->entity.
+   */
+  protected function createAnotherEntity() {
+    $entity = $this->entity->createDuplicate();
+    $label_key = $entity->getEntityType()->getKey('label');
+    if ($label_key) {
+      $entity->set($label_key, $entity->label() . '_dupe');
+    }
+    $entity->save();
+    return $entity;
+  }
+
+  /**
+   * 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());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    return (new CacheableMetadata())
+      ->setCacheTags(['4xx-response', 'http_response'])
+      ->setCacheContexts(['user.permissions']);
+  }
+
+  /**
+   * The expected cache tags for the GET/HEAD response of the test entity.
+   *
+   * @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:filter',
+      'url.query_args:include',
+      'url.query_args:page',
+      'url.query_args:sort',
+      // Drupal defaults.
+      'url.site',
+      'user.permissions',
+    ];
+  }
+
+  /**
+   * Sets up the necessary authorization.
+   *
+   * In case of a test verifying publicly accessible REST resources: grant
+   * permissions to the anonymous user role.
+   *
+   * In case of a test verifying behavior when using a particular authentication
+   * provider: create a user with a particular set of permissions.
+   *
+   * Because of the $method parameter, it's possible to first set up
+   * authentication for only GET, then add POST, et cetera. This then also
+   * allows for verifying a 403 in case of missing authorization.
+   *
+   * @param string $method
+   *   The HTTP method for which to set up authentication.
+   *
+   * @see ::grantPermissionsToAnonymousRole()
+   * @see ::grantPermissionsToAuthenticatedRole()
+   */
+  abstract protected function setUpAuthorization($method);
+
+  /**
+   * Return the expected error message.
+   *
+   * @param string $method
+   *   The HTTP method (GET, POST, PATCH, DELETE).
+   *
+   * @return string
+   *   The error string.
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    $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);
+  }
+
+  /**
+   * 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) {
+    $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());
+    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);
+    $this->assertSame($expected_document, $actual_document);
+  }
+
+  /**
+   * 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 \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, 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) {
+    $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 ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) {
+      $expected_error['links']['info'] = $info_url;
+    }
+    // @todo Remove in https://www.drupal.org/project/jsonapi/issues/2934362.
+    $expected_error['code'] = 0;
+    if ($pointer !== FALSE) {
+      $expected_error['source']['pointer'] = $pointer;
+    }
+
+    $expected_document = [
+      '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), [static::$entityTypeId => $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('GET', $url, $request_options);
+    $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+    $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to GET the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to GET the selected resource." . (strlen($reason) ? ' ' . $reason : ''), $response, '/data'); */
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2929428.
+    /* $this->assertResourceResponse(403, $expected_document, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS'); */
+    $this->assertArrayNotHasKey('Link', $response->getHeaders());
+
+    $this->setUpAuthorization('GET');
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    $this->assertResourceResponse(200, 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.
+    // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2942561#comment-12472704.
+    // @codingStandardsIgnoreStart
+    /*
+    $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), 'api_json', [
+      'target_entity' => static::$entityTypeId,
+      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+    ]);
+    $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+    */
+    // @codingStandardsIgnoreEnd
+    $get_headers = $response->getHeaders();
+
+    // Verify that the GET and HEAD responses are the same. The only difference
+    // is that there's no body. For this reason the 'Transfer-Encoding' and
+    // 'Vary' headers are also added to the list of headers to ignore, as they
+    // may be added to GET requests, depending on web server configuration. They
+    // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
+    $ignored_headers = [
+      'Date',
+      'Content-Length',
+      'X-Drupal-Cache',
+      'X-Drupal-Dynamic-Cache',
+      'Transfer-Encoding',
+      'Vary',
+    ];
+    $header_cleaner = function ($headers) use ($ignored_headers) {
+      foreach ($headers as $header => $value) {
+        if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
+          unset($headers[$header]);
+        }
+      }
+      return $headers;
+    };
+    $get_headers = $header_cleaner($get_headers);
+    $head_headers = $header_cleaner($head_headers);
+    $this->assertSame($get_headers, $head_headers);
+
+    // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932.
+    // @codingStandardsIgnoreStart
+    /*
+    // BC: serialization_update_8401().
+    // Only run this for fieldable entities. It doesn't make sense for config
+    // entities as config values always use the raw values (as per the config
+    // schema), returned directly from the ConfigEntityNormalizer, which
+    // doesn't deal with fields individually.
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Test the BC settings for timestamp values.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
+      // Rebuild the container so new config is reflected in the addition of the
+      // TimestampItemNormalizer.
+      $this->rebuildAll();
+
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
+
+      // This ensures the BC layer for bc_timestamp_normalizer_unix works as
+      // expected. This method should be using
+      // ::formatExpectedTimestampValue() to generate the timestamp value. This
+      // will take into account the above config setting.
+      $expected = $this->getExpectedNormalizedEntity();
+      // Config entities are not affected.
+      // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
+      static::recursiveKsort($expected);
+      $actual = Json::decode((string) $response->getBody());
+      static::recursiveKsort($actual);
+      $this->assertSame($expected, $actual);
+
+      // Reset the config value and rebuild.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
+      $this->rebuildAll();
+    }
+    */
+    // @codingStandardsIgnoreEnd
+
+    // 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), [static::$entityTypeId => $random_uuid]);
+    $response = $this->request('GET', $url, $request_options);
+    $message_url = clone $url;
+    $path = str_replace($random_uuid, '{' . static::$entityTypeId . '}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
+    $this->assertResourceErrorResponse(404, $message, $response);
+
+    // DX: when Accept request header is missing, still 404, but HTML response.
+    unset($request_options[RequestOptions::HEADERS]['Accept']);
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+  }
+
+  /**
+   * 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 GETing relationships of an individual resource.
+   *
+   * 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 testGetRelationships() {
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+    $this->doTestGetRelationships($request_options);
+    $this->setUpAuthorization('GET');
+    $this->doTestGetRelationships($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();
+    // 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.
+    $relationship_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 = $relationship_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 ::doTestRelated
+   */
+  protected function doTestGetRelationships(array $request_options) {
+    $relationship_field_names = $this->getRelationshipFieldNames();
+    // If there are no relationship fields, we can't test relationship routes.
+    if (empty($relationship_field_names)) {
+      return;
+    }
+    $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();
+      $actual_response = $related_responses[$relationship_field_name];
+      /* @var \Psr\Http\Message\ResponseInterface $actual_response */
+      $actual_document = Json::decode((string) $actual_response->getBody());
+      $this->assertSameDocument($expected_document, $actual_document);
+      $this->assertSame($expected_resource_response->getStatusCode(), $actual_response->getStatusCode());
+    }
+  }
+
+  /**
+   * Gets an expected ResourceResponse for the given relationship.
+   *
+   * @param string $relationship_field_name
+   *   The relationship for which to get an expected response.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The expected ResourceResponse.
+   */
+  protected function getExpectedGetRelationshipResponse($relationship_field_name) {
+    $access = $this->entityFieldAccess($this->entity, $relationship_field_name, 'view');
+    if (!$access->isAllowed()) {
+      $detail = 'The current user is not allowed to view this relationship.';
+      if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) {
+        $detail .= ' ' . $reason;
+      }
+      return (new ResourceResponse([
+        'errors' => [
+          [
+            'status' => 403,
+            'title' => 'Forbidden',
+            'detail' => $detail,
+            'links' => [
+              'info' => HttpExceptionNormalizer::getInfoUrl(403),
+            ],
+            'code' => 0,
+            'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+            'source' => [
+              'pointer' => $relationship_field_name,
+            ],
+          ],
+        ],
+      ], 403))->addCacheableDependency($access);
+    }
+    $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name);
+    $status_code = isset($expected_document['errors'][0]['status']) ? $expected_document['errors'][0]['status'] : 200;
+    $resource_response = new ResourceResponse($expected_document, $status_code);
+    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.
+   *
+   * @return array
+   *   The expected document array.
+   */
+  protected function getExpectedGetRelationshipDocument($relationship_field_name) {
+    $entity_type_id = $this->entity->getEntityTypeId();
+    $bundle = $this->entity->bundle();
+    $id = $this->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);
+    return [
+      'data' => $data,
+      // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2949807
+      // @codingStandardsIgnoreStart
+      //'jsonapi' => [
+      //  'meta' => [
+      //    'links' => [
+      //      'self' => 'http://jsonapi.org/format/1.0/',
+      //    ],
+      //  ],
+      //  'version' => '1.0',
+      //],
+      // @codingStandardsIgnoreEnd
+      'links' => [
+        'self' => $self_link,
+        'related' => $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.
+   *
+   * @return mixed
+   *   The expected document data.
+   */
+  protected function getExpectedGetRelationshipDocumentData($relationship_field_name) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+    $field = $this->entity->{$relationship_field_name};
+    $is_multiple = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() !== 1;
+    if ($field->isEmpty()) {
+      return $is_multiple ? [] : NULL;
+    }
+    if (!$is_multiple) {
+      $entity = $field->entity;
+      return is_null($entity) ? NULL : static::toResourceIdentifier($entity);
+    }
+    else {
+      return array_filter(array_map(function ($item) {
+        $entity = $item->entity;
+        return is_null($entity) ? NULL : static::toResourceIdentifier($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.
+   *
+   * @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) {
+    // Get the relationships responses which contain resource identifiers for
+    // every related resource.
+    $relationship_responses = static::toResourceResponses($this->getRelationshipResponses($relationship_field_names, $request_options));
+    foreach ($relationship_field_names as $relationship_field_name) {
+      $access = $this->entityFieldAccess($this->entity, $relationship_field_name, 'view');
+      if (!$access->isAllowed()) {
+        $detail = 'The current user is not allowed to view this relationship.';
+        if ($access instanceof AccessResultReasonInterface && ($reason = $access->getReason())) {
+          $detail .= ' ' . $reason;
+        }
+        $related_response = (new ResourceResponse([
+          'errors' => [
+            [
+              'status' => 403,
+              'title' => 'Forbidden',
+              'detail' => $detail,
+              'links' => [
+                'info' => HttpExceptionNormalizer::getInfoUrl(403),
+              ],
+              'code' => 0,
+              'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+              'source' => [
+                'pointer' => $relationship_field_name,
+              ],
+            ],
+          ],
+        ], 403))->addCacheableDependency($access);
+      }
+      else {
+        $self_link = static::getRelatedLink(static::toResourceIdentifier($this->entity), $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
+            : new ResourceResponse([
+              // Empty to-one relationships should be NULL and empty to-many
+              // relationships should be an empty array.
+              'data' => is_null($relationship_document['data']) ? NULL : [],
+              'jsonapi' => [
+                'meta' => [
+                  'links' => [
+                    'self' => 'http://jsonapi.org/format/1.0/',
+                  ],
+                ],
+                'version' => '1.0',
+              ],
+              'links' => ['self' => $self_link],
+            ]);
+        }
+        else {
+          $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']);
+          $resource_identifiers = $is_to_one_relationship
+            ? [$relationship_document['data']]
+            : $relationship_document['data'];
+          $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options));
+          $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship);
+        }
+      }
+      $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()));
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName));
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943170.
+    // @codingStandardsIgnoreStart
+    /*
+    // DX: 415 when no Content-Type request header. HTML response because
+    // missing ?_format query string.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => 'api_json']);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, '…', 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = '';
+
+    // DX: 400 when no request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+*/
+    // @codingStandardsIgnoreEnd
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943165.
+    /* $this->assertResourceErrorResponse(400, 'Bad Request', 'Syntax error', $response); */
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('POST');
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          // @todo Why is the reason missing here?
+          'detail' => "The current user is not allowed to POST the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to POST the selected resource." . (strlen($reason) ? ' ' . $reason : ''), $response, '/data'); */
+
+    $this->setUpAuthorization('POST');
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type;
+
+    // DX: 400 when invalid JSON API request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('POST', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/' . $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;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934386 lands.
+    // 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);
+      // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+      $expected_document = [
+        'errors' => [
+          [
+            'title' => 'Forbidden',
+            'status' => 403,
+            'detail' => "IDs should be properly generated and formatted UUIDs as described in RFC 4122.",
+            'links' => [
+              'info' => HttpExceptionNormalizer::getInfoUrl(403),
+            ],
+            'code' => 0,
+            'source' => [
+              'pointer' => '/data/id',
+            ],
+          ],
+        ],
+      ];
+      $this->assertResourceResponse(403, $expected_document, $response);
+      /* $this->assertResourceErrorResponse(403, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $response, '/data/id'); */
+    }
+
+    $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).", $response, '/data/attributes/field_rest_test');
+
+    $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);
+    // @todo Remove this logic to extract a UUID from the response in https://www.drupal.org/project/jsonapi/issues/2944977
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $uuid = $this->entityStorage->load(static::$firstCreatedEntityId)->uuid();
+    }
+    else {
+      $r = Json::decode((string) $response->getBody());
+      $uuid = NestedArray::getValue($r, ['data', 'id']);
+    }
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $uuid])->setAbsolute(TRUE)->toString();
+    /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
+    $this->assertSame([$location], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // If the entity is stored, perform extra checks.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      // Assert that the entity was indeed created, and that the response body
+      // contains the serialized created entity.
+      $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
+      $created_entity_document = $this->entityToJsonApi->normalize($created_entity);
+      // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
+      // its body unconditionally.
+      if (static::$entityTypeId !== 'taxonomy_term') {
+        $decoded_response_body = Json::decode((string) $response->getBody());
+        // @todo Remove the two lines below once https://www.drupal.org/project/jsonapi/issues/2925043 lands.
+        unset($created_entity_document['links']);
+        unset($decoded_response_body['links']);
+        $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.
+          $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], ['links' => TRUE]));
+        }
+      }
+    }
+
+    // 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);
+    // @todo Remove this logic to extract a UUID from the response in https://www.drupal.org/project/jsonapi/issues/2944977
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      $uuid = $this->entityStorage->load(static::$secondCreatedEntityId)->uuid();
+    }
+    else {
+      $r = Json::decode((string) $response->getBody());
+      $uuid = NestedArray::getValue($r, ['data', 'id']);
+    }
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $uuid])->setAbsolute(TRUE)->toString();
+    /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */
+    $this->assertSame([$location], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+    if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
+      // 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.', $response);
+
+      // 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();
+    }
+
+  }
+
+  /**
+   * 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();
+
+    // 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()));
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    /* $url = $this->entity->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json';
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions());
+
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943170.
+    // @codingStandardsIgnoreStart
+    /*
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+    // DX: 400 when no request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+*/
+    // @codingStandardsIgnoreEnd
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943165.
+    /* $this->assertResourceErrorResponse(400, 'Syntax error', $response); */
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH');
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to PATCH the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected resource." . (strlen($reason) ? ' ' . $reason : ''), $response, '/data'); */
+
+    $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 = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/' . $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 = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to PATCH the selected field (field_rest_test).",
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          '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 = [
+      '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' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          '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()), $response);
+    }
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+
+    // DX: 403 when entity contains field without 'edit' nor 'view' access, even
+    // when the value for that field matches the current value. This is allowed
+    // in principle, but leads to information disclosure.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to PATCH the selected field (field_rest_test).",
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          '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] = $this->entityToJsonApi->serialize($modified_entity);
+      $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 = [
+        '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' => HttpExceptionNormalizer::getInfoUrl(403),
+            ],
+            'code' => 0,
+            'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+            '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]);
+    }
+
+    // 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->entityToJsonApi->normalize($this->entity), $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;
+
+    // @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('PATCH', $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';
+
+    // 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->entityToJsonApi->normalize($updated_entity);
+    $this->assertSame($updated_entity_document, Json::decode((string) $response->getBody()));
+    // Assert that the entity was indeed created using the PATCHed values.
+    foreach ($this->getPatchDocument() as $field_name => $field_normalization) {
+      // Some top-level keys in the normalization may not be fields on the
+      // entity (for example '_links' and '_embedded' in the HAL normalization).
+      if ($updated_entity->hasField($field_name)) {
+        // Subset, not same, because we can e.g. send just the target_id for the
+        // bundle in a PATCH request; the response will include more properties.
+        $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
+      }
+    }
+
+    // Ensure that fields do not get deleted if they're not present in the PATCH
+    // request. Test this using the configurable field that we added, but which
+    // is not sent in the PATCH request.
+    $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
+
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return;
+    }
+
+    // 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), [static::$entityTypeId => $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');
+    // @todo Remove $expected + assertResourceResponse() in favor of the commented line below once https://www.drupal.org/project/jsonapi/issues/2943176 lands.
+    $expected_document = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to DELETE the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, $expected_document, $response);
+    /* $this->assertResourceErrorResponse(403, "The current user is not allowed to DELETE the selected resource." . (strlen($reason) ? ' ' . $reason : ''), $response, '/data'); */
+
+    $this->setUpAuthorization('DELETE');
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertResourceResponse(204, NULL, $response);
+  }
+
+  /**
+   * Transforms a normalization: casts all non-string types to strings.
+   *
+   * @param array $normalization
+   *   A normalization to transform.
+   *
+   * @return array
+   *   The transformed normalization.
+   */
+  protected static function castToString(array $normalization) {
+    foreach ($normalization as $key => $value) {
+      if (is_bool($value)) {
+        $normalization[$key] = (string) (int) $value;
+      }
+      elseif (is_int($value) || is_float($value)) {
+        $normalization[$key] = (string) $value;
+      }
+      elseif (is_array($value)) {
+        $normalization[$key] = static::castToString($value);
+      }
+    }
+    return $normalization;
+  }
+
+  /**
+   * Recursively sorts an array by key.
+   *
+   * @param array $array
+   *   An array to sort.
+   */
+  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) {
+    foreach ($this->getSparseFieldSets() 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)];
+      $url->setOption('query', $query);
+      $response = $this->request('GET', $url, $request_options);
+      // Get the expected document and remove any unwanted fields.
+      $expected_document = $this->getExpectedDocument();
+      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;
+          }
+        }
+      }
+      // 'self' link should include the 'fields' query param.
+      $expected_document['links']['self'] = $url->setAbsolute()->toString();
+      // Dynamic Page Cache miss because cache should vary based on the 'field'
+      // query param.
+      $this->assertResourceResponse(
+        200,
+        $expected_document,
+        $response,
+        $this->getExpectedCacheTags($field_set),
+        $this->getExpectedCacheContexts($field_set),
+        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, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), 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();
+    // If there are no relationship fields, we can't include anything.
+    if (empty($relationship_field_names)) {
+      return;
+    }
+    // Builds a map of relationship field names to related resources by making
+    // requests to the 'related' link in the document. We will later merge this
+    // into an expected response so that we can verify all the included
+    // data and cacheable metadata.
+    $related_responses = static::toResourceResponses($this->getRelatedResponses($relationship_field_names, $request_options));
+    $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);
+    }
+    foreach ($field_sets as $type => $field_set) {
+      $query = ['include' => implode(',', $field_set)];
+      $url->setOption('query', $query);
+      $response = $this->request('GET', $url, $request_options);
+      // The expected response is based on the expected individual response for
+      // this resource type, it will then be decorated using the related
+      // response data.
+      $expected_document = $this->getExpectedDocument();
+      // Update the expected 'self' link with expected include query parameter.
+      $expected_document['links']['self'] = $url->setAbsolute()->toString();
+      $expected_cacheability = (new CacheableMetadata())
+        ->setCacheContexts($this->getExpectedCacheContexts())
+        ->setCacheTags($this->getExpectedCacheTags());
+      $expected_response = static::decorateExpectedResponseForIncludedFields(
+        (new ResourceResponse($expected_document))->addCacheableDependency($expected_cacheability),
+        array_intersect_key($related_responses, array_flip($field_set))
+      );
+      $response_document = Json::decode((string) $response->getBody());
+      $expected_document = $expected_response->getResponseData();
+      if (!empty($expected_document['meta']['errors'])) {
+        // The 'related' responses will not have included document pointers, so
+        // we can't assert those here either.
+        foreach ($response_document['meta']['errors'] as &$error) {
+          unset($error['source']['pointer']);
+        }
+      }
+      if (!empty($expected_document['included'])) {
+        static::sortResourceCollection($expected_document['included']);
+        static::sortResourceCollection($response_document['included']);
+      }
+      // @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,
+      //   $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 data. 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());
+      if (!empty($related_document['errors'])) {
+        // If any of the related response documents had top-level errors, we
+        // should later expect the document to have 'meta' errors too.
+        foreach ($related_document['errors'] as $error) {
+          // @todo remove this conditional when inaccessible relationships are able to raise errors.
+          if ($error['detail'] !== 'The current user is not allowed to view this relationship.') {
+            unset($error['source']['pointer']);
+            $expected_document['meta']['errors'][] = $error;
+          }
+        }
+      }
+      elseif (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);
+  }
+
+  /**
+   * 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());
+    return [
+      'empty' => [],
+      'some' => array_slice($field_names, floor(count($field_names) / 2)),
+      'all' => $field_names,
+    ];
+  }
+
+  /**
+   * Gets a list of relationship field names for the resource type under test.
+   *
+   * @return array
+   *   An array of relationship field names.
+   */
+  protected function getRelationshipFieldNames() {
+    // Only content entity types can have relationships.
+    $fields = $this->entity instanceof ContentEntityInterface
+      ? iterator_to_array($this->entity)
+      : [];
+    return array_reduce($fields, function ($field_names, $field) {
+      /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+      if ($this->isReferenceFieldDefinition($field->getFieldDefinition())) {
+        $field_names[] = $field->getName();
+      }
+      return $field_names;
+    }, []);
+  }
+
+  /**
+   * Check access for the given operation, field and 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.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The AccessResult.
+   */
+  protected function entityFieldAccess(EntityInterface $entity, $field_name, $operation) {
+    // 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($this->entity->getEntityTypeId())->resetCache();
+
+    $field_access = $entity->{$field_name}->access($operation, $this->account, TRUE);
+    $entity_access = $entity->access($operation, $this->account, TRUE);
+    return $entity_access->andIf($field_access);
+  }
+
+  /**
+   * 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;
+  }
+
+}
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..ad9f41e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ResponsiveImageStyleTest.php
@@ -0,0 +1,143 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'responsive_image_style--responsive_image_style',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'breakpoint_group' => 'test_group',
+          'dependencies' => [
+            'config' => [
+              'image.style.large',
+              'image.style.medium',
+            ],
+          ],
+          'fallback_image_style' => 'fallback',
+          'id' => 'camelids',
+          '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,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..6fa3623
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RestJsonApiUnsupported.php
@@ -0,0 +1,118 @@
+<?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(TRUE)
+      ->save();
+  }
+
+  /**
+   * Deploying a REST resource using api_json format results in 406 responses.
+   */
+  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(406, FALSE, $response);
+  }
+
+  /**
+   * {@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..fb8a74e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RestResourceConfigTest.php
@@ -0,0 +1,127 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'rest_resource_config--rest_resource_config',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [
+            'module' => [
+              'dblog',
+              'serialization',
+              'user',
+            ],
+          ],
+          'id' => 'llama',
+          'plugin_id' => 'dblog',
+          'granularity' => 'method',
+          'configuration' => [
+            'GET' => [
+              'supported_formats' => [
+                'json',
+              ],
+              'supported_auth' => [
+                'cookie',
+              ],
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..80a8b3b
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/RoleTest.php
@@ -0,0 +1,103 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user_role--user_role',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'weight' => 2,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'id' => 'llama',
+          'label' => NULL,
+          'is_admin' => NULL,
+          'permissions' => [],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..78aa492
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/SearchPageTest.php
@@ -0,0 +1,142 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'search_page--search_page',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'configuration' => [
+            'rankings' => [],
+          ],
+          'dependencies' => [
+            'module' => [
+              'node',
+            ],
+          ],
+          'id' => 'hinode_search',
+          'label' => 'Search of magnetic activity of the Sun',
+          'langcode' => 'en',
+          'path' => 'sun',
+          'plugin' => 'node_search',
+          'status' => TRUE,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..e1b1bef
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ShortcutSetTest.php
@@ -0,0 +1,111 @@
+<?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 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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'shortcut_set--shortcut_set',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 'llama_set',
+          'uuid' => $this->entity->uuid(),
+          'label' => 'Llama Set',
+          'status' => TRUE,
+          'langcode' => 'en',
+          'dependencies' => [],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..b9ce5db
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ShortcutTest.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\shortcut\Entity\Shortcut;
+use Drupal\shortcut\Entity\ShortcutSet;
+
+/**
+ * JSON API integration test for the "Shortcut" content entity type.
+ *
+ * @group jsonapi
+ */
+class ShortcutTest extends ResourceTestBase {
+
+  /**
+   * {@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:/admin/content/comment',
+      ],
+    ]);
+    $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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'shortcut--default',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'id' => (int) $this->entity->id(),
+          'title' => 'Comments',
+          'link' => [
+            'uri' => 'internal:/admin/content/comment',
+            'title' => NULL,
+            'options' => [],
+          ],
+          'langcode' => 'en',
+          'default_langcode' => TRUE,
+          'weight' => -20,
+        ],
+        'relationships' => [
+          'shortcut_set' => [
+            'data' => [
+              'type' => 'shortcut_set--shortcut_set',
+              'id' => ShortcutSet::load('default')->uuid(),
+            ],
+            'links' => [
+              'related' => $self_url . '/shortcut_set',
+              'self' => $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.";
+  }
+
+}
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..2df62f8
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TermTest.php
@@ -0,0 +1,427 @@
+<?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\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON API integration test for the "Term" content entity type.
+ *
+ * @group jsonapi
+ */
+class TermTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@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':
+        // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+        if (floatval(\Drupal::VERSION) < 8.5) {
+          $this->grantPermissionsToTestedRole(['administer taxonomy']);
+        }
+        $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]:
+        // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339
+        $expected_parent_normalization = [
+          'data' => [],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+
+      case [2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+
+      case [0, 2]:
+        $expected_parent_normalization = [
+          'data' => [
+            // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $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' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+    }
+
+    // @todo Remove this when JSON API requires Drupal 8.6 or newer.
+    if (floatval(\Drupal::VERSION) < 8.6) {
+      $expected_parent_normalization = [
+        'data' => [],
+        'links' => [
+          'related' => $self_url . '/parent',
+          'self' => $self_url . '/relationships/parent',
+        ],
+      ];
+    }
+
+    $document = [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_term--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          /* 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), */
+          'default_langcode' => TRUE,
+          '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',
+          ],
+          'tid' => 1,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+        'relationships' => [
+          'parent' => $expected_parent_normalization,
+          'vid' => [
+            'data' => [
+              'id' => Vocabulary::load('camelids')->uuid(),
+              'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+            ],
+            'links' => [
+              'related' => $self_url . '/vid',
+              'self' => $self_url . '/relationships/vid',
+            ],
+          ],
+        ],
+      ],
+    ];
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      unset($document['data']['attributes']['description']['processed']);
+    }
+    return $document;
+  }
+
+  /**
+   * {@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.";
+
+      case 'POST':
+        // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+        if (floatval(\Drupal::VERSION) < 8.5) {
+          return "The 'administer taxonomy' permission is required.";
+        }
+        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);
+    }
+  }
+
+  /**
+   * 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), [static::$entityTypeId => $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());
+
+    // 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) {
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return parent::getExpectedCacheTags($sparse_fieldset);
+    }
+
+    $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) {
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return parent::getExpectedCacheContexts($sparse_fieldset);
+    }
+
+    $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) {
+    if (floatval(\Drupal::VERSION) < 8.6) {
+      $this->markTestSkipped('The "parent" field on terms is only available for normalization in Drupal 8.6 and later.');
+      return;
+    }
+
+    // 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), [static::$entityTypeId => $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],
+      ],
+    ];
+  }
+
+}
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..abf1b46
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TestCoverageTest.php
@@ -0,0 +1,122 @@
+<?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();
+
+    // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+    if (floatval(\Drupal::VERSION) < 8.5) {
+      return;
+    }
+
+    // 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..e2daee5
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/TourTest.php
@@ -0,0 +1,143 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'tour--tour',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [],
+          'id' => 'tour-llama',
+          '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',
+              ],
+            ],
+          ],
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..fc21d7a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php
@@ -0,0 +1,447 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * JSON API integration test for the "User" content entity type.
+ *
+ * @group jsonapi
+ */
+class UserTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $resourceTypeName = 'user--user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * {@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() {
+    /** @var \Drupal\user\UserInterface $user */
+    $user = $this->entity->createDuplicate();
+    $user->setUsername($user->label() . '_dupe');
+    $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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user--user',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          // 'created' => $this->formatExpectedTimestampItemValues(123456789),
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+          // 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'uid' => 3,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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':
+      case 'DELETE':
+        return '';
+
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields of the logged in account.
+   */
+  public function testPatchDxForSecuritySensitiveBaseFields() {
+    $original_normalization = $this->entityToJsonApi->normalize($this->account);
+    // @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.
+    // @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'), ['user' => $this->account->uuid()]);
+    /* $url = $this->account->toUrl('jsonapi'); */
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = '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 = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'mail: Your current password is missing or incorrect; it\'s required to change the Email.',
+          'code' => 0,
+          '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 = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'pass: Your current password is missing or incorrect; it\'s required to change the Password.',
+          'code' => 0,
+          '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 = 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 = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to PATCH the selected field (name).',
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/user--user/' . $this->account->uuid(),
+          '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() {
+    $original_normalization = $this->entityToJsonApi->normalize($this->account);
+
+    // Since this test must be performed by the user that is being modified,
+    // we must use $this->account, not $this->entity.
+    // @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'), ['user' => $this->account->uuid()]);
+    /* $url = $this->account->toUrl('jsonapi'); */
+    $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' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          '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'), ['user' => $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->assertArrayHasKey('mail', $doc['data'][1]['attributes']);
+    $this->assertArrayNotHasKey('mail', $doc['data'][3]['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->assertArrayNotHasKey('mail', $doc['data'][1]['attributes']);
+    $this->assertArrayHasKey('mail', $doc['data'][3]['attributes']);
+  }
+
+}
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..97644e6
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/ViewTest.php
@@ -0,0 +1,123 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'view--view',
+        'links' => [
+          'self' => $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' => [],
+              ],
+            ],
+          ],
+          'id' => 'test_rest',
+          'label' => 'Test REST',
+          'langcode' => 'en',
+          'module' => 'views',
+          'status' => TRUE,
+          'tag' => '',
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..be4d04e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/VocabularyTest.php
@@ -0,0 +1,116 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'vid' => 'llama',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'name' => 'Llama',
+          'description' => NULL,
+          'hierarchy' => 0,
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPostDocument() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($method === 'GET') {
+      // @todo Remove this when JSON API requires Drupal 8.5 or newer.
+      if (floatval(\Drupal::VERSION) < 8.5) {
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+      }
+      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..b4c41ab
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/WorkflowTest.php
@@ -0,0 +1,127 @@
+<?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' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'workflow--workflow',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'dependencies' => [
+            'module' => [
+              'workflow_type_test',
+            ],
+          ],
+          'id' => 'rest_workflow',
+          'label' => 'REST Worklow',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'type' => 'workflow_type_complex_test',
+          'type_settings' => [
+            'states' => [
+              'draft' => [
+                'extra' => 'bar',
+                'label' => 'Draft',
+                'weight' => 0,
+              ],
+              'published' => [
+                'label' => 'Published',
+                'weight' => 1,
+              ],
+            ],
+            'transitions' => [],
+            'example_setting' => 'foo',
+          ],
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@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..26a6796
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Context/FieldResolverTest.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Context;
+
+use Drupal\entity_test\Entity\EntityTestBundle;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Context\FieldResolver
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class FieldResolverTest extends JsonapiKernelTestBase {
+
+  public static $modules = [
+    'entity_test',
+    'serialization',
+    'field',
+    'text',
+    'user',
+  ];
+
+  /**
+   * The subject under test.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $sut;
+
+  /**
+   * {@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']);
+  }
+
+  /**
+   * @covers ::resolveInternal
+   * @dataProvider resolveInternalProvider
+   */
+  public function testResolveInternal($expect, $external_path, $entity_type_id = 'entity_test_with_bundle', $bundle = 'bundle1') {
+    $this->assertEquals($expect, $this->sut->resolveInternal($entity_type_id, $bundle, $external_path));
+  }
+
+  /**
+   * Provides test cases for field resolution.
+   */
+  public function resolveInternalProvider() {
+    return [
+      // Config entities.
+      ['uuid', 'uuid', 'entity_test_bundle', 'entity_test_bundle'],
+      ['type.entity.uuid', 'type.uuid'],
+
+      // Content entities.
+      ['field_test1', 'field_test1'],
+      ['field_test2', 'field_test2'],
+
+      ['field_test_ref2.entity.field_test1', 'field_test_ref2.field_test1'],
+      ['field_test_ref2.entity.field_test2', 'field_test_ref2.field_test2'],
+
+      ['field_test_ref1.entity.field_test_text', 'field_test_ref1.field_test_text'],
+      ['field_test_ref1.entity.field_test_text.value', 'field_test_ref1.field_test_text.value'],
+      ['field_test_ref1.entity.field_test_text.format', 'field_test_ref1.field_test_text.format'],
+      ['field_test_ref2.entity.field_test_text', 'field_test_ref2.field_test_text'],
+      ['field_test_ref2.entity.field_test_text.value', 'field_test_ref2.field_test_text.value'],
+      ['field_test_ref2.entity.field_test_text.format', 'field_test_ref2.field_test_text.format'],
+    ];
+  }
+
+  /**
+   * Expects an error when an invalid field is provided.
+   *
+   * @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.
+   *
+   * @covers ::resolveInternal
+   * @dataProvider resolveInternalErrorProvider
+   */
+  public function testResolveInternalError($entity_type, $bundle, $external_path) {
+    $this->setExpectedException(BadRequestHttpException::class);
+    $this->sut->resolveInternal($entity_type, $bundle, $external_path);
+  }
+
+  /**
+   * Provides test cases for ::testResolveInternalError.
+   */
+  public function resolveInternalErrorProvider() {
+    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'],
+    ];
+  }
+
+  /**
+   * 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..1ec3699
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -0,0 +1,974 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Config\ConfigException;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Controller\EntityResource;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\Resource\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\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Controller\EntityResource
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityResourceTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@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;
+
+  /**
+   * {@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(),
+    ]);
+    $this->node->save();
+
+    $this->node2 = Node::create([
+      'type' => 'article',
+      'title' => 'Another test node',
+      'uid' => $this->user->id(),
+    ]);
+    $this->node2->save();
+
+    $this->node3 = Node::create([
+      'type' => 'article',
+      'title' => 'Unpublished test node',
+      'uid' => $this->user->id(),
+      'status' => 0,
+    ]);
+    $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()],
+      ],
+    ]);
+    $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']);
+  }
+
+  /**
+   * @covers ::getIndividual
+   */
+  public function testGetIndividual() {
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->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();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $this->setExpectedException(EntityAccessDeniedHttpException::class);
+    $entity_resource->getIndividual($this->node, new Request());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetCollection() {
+    $request = new Request([], [], [
+      '_route_params' => ['_json_api_params' => []],
+      '_json_api_params' => [],
+    ]);
+
+    // Get the response.
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->getCollection($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() {
+    $field_manager = $this->container->get('entity_field.manager');
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('type', 'article')]));
+    // The fake route.
+    $route = new Route(NULL, [], [
+      '_entity_type' => 'node',
+      '_bundle' => 'article',
+    ]);
+    // The request.
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'filter' => $filter,
+        ],
+      ],
+      '_json_api_params' => [
+        'filter' => $filter,
+      ],
+      '_route_object' => $route,
+    ]);
+    $request_stack = new RequestStack();
+    $request_stack->push($request);
+    // Get the entity resource.
+    $current_context = new CurrentContext(
+      $this->container->get('jsonapi.resource_type.repository'),
+      $request_stack,
+      new CurrentRouteMatch($request_stack)
+    );
+    $this->container->set('jsonapi.current_context', $current_context);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'),
+      $this->container->get('entity_type.manager'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository')
+    );
+
+    // Get the response.
+    $response = $entity_resource->getCollection($request);
+
+    // Assertions.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
+    $this->assertCount(1, $response->getResponseData()->getData());
+    $this->assertEquals(['config:node_type_list'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetSortedCollection() {
+    // Fake the request.
+    $field_manager = $this->container->get('entity_field.manager');
+    // The fake route.
+    $route = new Route(NULL, [], [
+      '_entity_type' => 'node',
+      '_bundle' => 'article',
+    ]);
+    $sort = new Sort([['path' => 'type', 'direction' => 'DESC']]);
+    // The request.
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'sort' => $sort,
+        ],
+      ],
+      '_json_api_params' => [
+        'sort' => $sort,
+      ],
+      '_route_object' => $route,
+    ]);
+    $request_stack = new RequestStack();
+    $request_stack->push($request);
+    // Get the entity resource.
+    $current_context = new CurrentContext(
+      $this->container->get('jsonapi.resource_type.repository'),
+      $request_stack,
+      new CurrentRouteMatch($request_stack)
+    );
+    $this->container->set('jsonapi.current_context', $current_context);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'),
+      $this->container->get('entity_type.manager'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository')
+    );
+
+    // Get the response.
+    $response = $entity_resource->getCollection($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');
+    $this->assertEquals(['config:node_type_list'], $response->getCacheableMetadata()->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCollection
+   */
+  public function testGetPagedCollection() {
+    // Fake the request.
+    $field_manager = $this->container->get('entity_field.manager');
+    // The fake route.
+    $route = new Route(NULL, [], [
+      '_entity_type' => 'node',
+      '_bundle' => 'article',
+    ]);
+    $pager = new OffsetPage(1, 1);
+    // The request.
+    $request = new Request([], [], [
+      '_route_params' => [
+        '_json_api_params' => [
+          'page' => $pager,
+        ],
+      ],
+      '_json_api_params' => [
+        'page' => $pager,
+      ],
+      '_route_object' => $route,
+    ]);
+    $request_stack = new RequestStack();
+    $request_stack->push($request);
+    // Get the entity resource.
+    $current_context = new CurrentContext(
+      $this->container->get('jsonapi.resource_type.repository'),
+      $request_stack,
+      new CurrentRouteMatch($request_stack)
+    );
+    $this->container->set('jsonapi.current_context', $current_context);
+
+    $entity_resource = new EntityResource(
+      $this->container->get('jsonapi.resource_type.repository')->get('node', 'article'),
+      $this->container->get('entity_type.manager'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository')
+    );
+
+    // Get the response.
+    $response = $entity_resource->getCollection($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.
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->getCollection($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.
+    $entity_resource = $this->buildEntityResource('node', 'article', [
+      'uid' => [new ResourceType('user', 'user', NULL)],
+      'roles' => [new ResourceType('user_role', 'user_role', NULL)],
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $response = $entity_resource->getRelated($this->node, 'uid', new Request());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertInstanceOf(User::class, $response->getResponseData()
+      ->getData());
+    $this->assertEquals(1, $response->getResponseData()->getData()->id());
+    $this->assertEquals(
+      ['node:1', 'user:1'],
+      $response->getCacheableMetadata()->getCacheTags()
+    );
+    // to-many relationship.
+    $response = $entity_resource->getRelated($this->node4, 'field_relationships', new Request());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response
+      ->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response
+      ->getResponseData()
+      ->getData());
+    $this->assertEquals(
+      ['node:1', 'node:2', 'node:3', 'node:4'],
+      $response->getCacheableMetadata()->getCacheTags()
+    );
+  }
+
+  /**
+   * @covers ::getRelationship
+   */
+  public function testGetRelationship() {
+    // to-one relationship.
+    $entity_resource = $this->buildEntityResource('node', 'article', [
+      'uid' => [new ResourceType('user', 'user', NULL)],
+    ]);
+    $response = $entity_resource->getRelationship($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();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->createIndividual($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.');
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $entity_resource->createIndividual($node, new Request());
+  }
+
+  /**
+   * @covers ::createIndividual
+   */
+  public function testCreateIndividualConfig() {
+    $node_type = NodeType::create([
+      'type' => 'test',
+      'name' => 'Test Type',
+      'description' => 'Lorem ipsum',
+    ]);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('administer content types')
+      ->save();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->createIndividual($node_type, new Request());
+    // As a side effect, the node type will also be saved.
+    $this->assertNotEmpty($node_type->id());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $this->assertEquals('test', $response->getResponseData()->getData()->id());
+    $this->assertEquals(201, $response->getStatusCode());
+  }
+
+  /**
+   * @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.');
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $entity_resource->createIndividual($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.
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->patchIndividual($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 ::patchIndividual
+   * @dataProvider patchIndividualConfigProvider
+   */
+  public function testPatchIndividualConfig($values) {
+    // List of fields to be ignored.
+    $ignored_fields = ['uuid', 'entityTypeId', 'type'];
+    $node_type = NodeType::create([
+      'type' => 'test',
+      'name' => 'Test Type',
+      'description' => '',
+    ]);
+    $node_type->save();
+
+    $parsed_node_type = NodeType::create($values);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('administer content types')
+      ->save();
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+    $payload = Json::encode([
+      'data' => [
+        'type' => 'node_type',
+        'id' => $node_type->uuid(),
+        'attributes' => $values,
+      ],
+    ]);
+    $request = new Request([], [], [], [], [], [], $payload);
+
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->patchIndividual($node_type, $parsed_node_type, $request);
+
+    // As a side effect, the node will also be saved.
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
+    $updated_node_type = $response->getResponseData()->getData();
+    $this->assertInstanceOf(NodeType::class, $updated_node_type);
+    // If the field is ignored then we should not see a difference.
+    foreach ($values as $field_name => $value) {
+      in_array($field_name, $ignored_fields) ?
+        $this->assertNotSame($value, $node_type->get($field_name)) :
+        $this->assertSame($value, $node_type->get($field_name));
+    }
+    $this->assertEquals(200, $response->getStatusCode());
+  }
+
+  /**
+   * Provides data for the testPatchIndividualConfig.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function patchIndividualConfigProvider() {
+    return [
+      [['description' => 'PATCHED', 'status' => FALSE]],
+      [[]],
+    ];
+  }
+
+  /**
+   * @covers ::patchIndividual
+   * @dataProvider patchIndividualConfigFailedProvider
+   */
+  public function testPatchIndividualFailedConfig($values) {
+    $this->setExpectedException(ConfigException::class);
+    $this->testPatchIndividualConfig($values);
+  }
+
+  /**
+   * Provides data for the testPatchIndividualFailedConfig.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function patchIndividualConfigFailedProvider() {
+    return [
+      [['uuid' => 'PATCHED']],
+      [['type' => 'article', 'status' => FALSE]],
+    ];
+  }
+
+  /**
+   * @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();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->deleteIndividual($node, new Request());
+    // 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 ::deleteIndividual
+   */
+  public function testDeleteIndividualConfig() {
+    $node_type = NodeType::create([
+      'type' => 'test',
+      'name' => 'Test Type',
+      'description' => 'Lorem ipsum',
+    ]);
+    $id = $node_type->id();
+    $node_type->save();
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('administer content types')
+      ->save();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $response = $entity_resource->deleteIndividual($node_type, new Request());
+    // As a side effect, the node will also be deleted.
+    $count = $this->container->get('entity_type.manager')
+      ->getStorage('node_type')
+      ->getQuery()
+      ->condition('type', $id)
+      ->count()
+      ->execute();
+    $this->assertEquals(0, $count);
+    $this->assertNull($response->getResponseData());
+    $this->assertEquals(204, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::createRelationship
+   */
+  public function testCreateRelationship() {
+    $parsed_field_list = $this->container
+      ->get('plugin.manager.field.field_type')
+      ->createFieldItemList($this->node, 'field_relationships', [
+        ['target_id' => $this->node->id()],
+      ]);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $entity_resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $response = $entity_resource->createRelationship($this->node, 'field_relationships', $parsed_field_list, 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(201, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::patchRelationship
+   * @dataProvider patchRelationshipProvider
+   */
+  public function testPatchRelationship($relationships) {
+    $this->node->field_relationships->appendItem(['target_id' => $this->node->id()]);
+    $this->node->save();
+    $parsed_field_list = $this->container
+      ->get('plugin.manager.field.field_type')
+      ->createFieldItemList($this->node, 'field_relationships', $relationships);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $entity_resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $response = $entity_resource->patchRelationship($this->node, 'field_relationships', $parsed_field_list, 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($relationships, $field_list->getValue());
+    $this->assertEquals(200, $response->getStatusCode());
+  }
+
+  /**
+   * Provides data for the testPatchRelationship.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function patchRelationshipProvider() {
+    return [
+      // Replace relationships.
+      [[['target_id' => 2], ['target_id' => 1]]],
+      // Remove relationships.
+      [[]],
+    ];
+  }
+
+  /**
+   * @covers ::deleteRelationship
+   * @dataProvider deleteRelationshipProvider
+   */
+  public function testDeleteRelationship($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();
+    $parsed_field_list = $this->container
+      ->get('plugin.manager.field.field_type')
+      ->createFieldItemList($this->node, 'field_relationships', $deleted_rels);
+    Role::load(Role::ANONYMOUS_ID)
+      ->grantPermission('edit any article content')
+      ->save();
+
+    $entity_resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [new ResourceType('node', 'article', NULL)],
+    ]);
+    $response = $entity_resource->deleteRelationship($this->node, 'field_relationships', $parsed_field_list, 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(201, $response->getStatusCode());
+  }
+
+  /**
+   * @covers ::getRelated
+   */
+  public function testGetRelatedInternal() {
+    $internal_resource_type = new ResourceType('node', 'article', NULL, TRUE);
+    $resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [$internal_resource_type],
+    ]);
+
+    $this->setExpectedException(NotFoundHttpException::class);
+    $resource->getRelationship($this->node, 'field_relationships', new Request());
+  }
+
+  /**
+   * @covers ::getRelationship
+   */
+  public function testGetRelationshipInternal() {
+    $internal_resource_type = new ResourceType('node', 'article', NULL, TRUE);
+    $resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [$internal_resource_type],
+    ]);
+
+    $this->setExpectedException(NotFoundHttpException::class);
+    $resource->getRelationship($this->node, 'field_relationships', new Request());
+  }
+
+  /**
+   * @covers ::createRelationship
+   */
+  public function testCreateRelationshipInternal() {
+    $internal_resource_type = new ResourceType('node', 'article', NULL, TRUE);
+    $resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [$internal_resource_type],
+    ]);
+
+    Role::load(Role::ANONYMOUS_ID)->grantPermission('edit any article content')->save();
+
+    $field_type_manager = $this->container->get('plugin.manager.field.field_type');
+    $list = $field_type_manager->createFieldItemList($this->node, 'field_relationships');
+
+    $this->setExpectedException(NotFoundHttpException::class);
+    $resource->createRelationship($this->node, 'field_relationships', $list, new Request());
+  }
+
+  /**
+   * @covers ::patchRelationship
+   */
+  public function testPatchRelationshipInternal() {
+    $internal_resource_type = new ResourceType('node', 'article', NULL, TRUE);
+    $resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [$internal_resource_type],
+    ]);
+
+    Role::load(Role::ANONYMOUS_ID)->grantPermission('edit any article content')->save();
+
+    $field_type_manager = $this->container->get('plugin.manager.field.field_type');
+    $list = $field_type_manager->createFieldItemList($this->node, 'field_relationships');
+
+    $this->setExpectedException(NotFoundHttpException::class);
+    $resource->patchRelationship($this->node, 'field_relationships', $list, new Request());
+  }
+
+  /**
+   * @covers ::deleteRelationship
+   */
+  public function testDeleteRelationshipInternal() {
+    $internal_resource_type = new ResourceType('node', 'article', NULL, TRUE);
+    $resource = $this->buildEntityResource('node', 'article', [
+      'field_relationships' => [$internal_resource_type],
+    ]);
+
+    Role::load(Role::ANONYMOUS_ID)->grantPermission('edit any article content')->save();
+
+    $field_type_manager = $this->container->get('plugin.manager.field.field_type');
+    $list = $field_type_manager->createFieldItemList($this->node, 'field_relationships');
+
+    $this->setExpectedException(NotFoundHttpException::class);
+    $resource->deleteRelationship($this->node, 'field_relationships', $list, new Request());
+  }
+
+  /**
+   * Provides data for the testDeleteRelationship.
+   *
+   * @return array
+   *   The input data for the test function.
+   */
+  public function deleteRelationshipProvider() {
+    return [
+      // Remove one relationship.
+      [[['target_id' => 1]], [['target_id' => 2]]],
+      // Remove all relationships.
+      [[['target_id' => 2], ['target_id' => 1]], []],
+      // Remove no relationship.
+      [[], [['target_id' => 1], ['target_id' => 2]]],
+    ];
+  }
+
+  /**
+   * Instantiates a test EntityResource.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   * @param string $bundle
+   *   The bundle.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $relatable_resource_types
+   *   An array of relatable resource types, keyed by field.
+   * @param bool $internal
+   *   Whether the primary resource type is internal.
+   *
+   * @return \Drupal\jsonapi\Controller\EntityResource
+   *   The resource.
+   */
+  protected function buildEntityResource($entity_type_id, $bundle, array $relatable_resource_types = [], $internal = FALSE) {
+    // The fake route.
+    $route = new Route(NULL, [], [
+      '_entity_type' => $entity_type_id,
+      '_bundle' => $bundle,
+    ]);
+    // The request.
+    $request = new Request([], [], ['_route_object' => $route]);
+    $request_stack = new RequestStack();
+    $request_stack->push($request);
+    // Get the entity resource.
+    $current_context = new CurrentContext(
+      $this->container->get('jsonapi.resource_type.repository'),
+      $request_stack,
+      new CurrentRouteMatch($request_stack)
+    );
+    $this->container->set('jsonapi.current_context', $current_context);
+
+    $resource_type = new ResourceType($entity_type_id, $bundle, NULL, $internal);
+    $resource_type->setRelatableResourceTypes($relatable_resource_types);
+
+    return new EntityResource(
+      $resource_type,
+      $this->container->get('entity_type.manager'),
+      $this->container->get('entity_field.manager'),
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type'),
+      $this->container->get('jsonapi.link_manager'),
+      $this->container->get('jsonapi.resource_type.repository')
+    );
+  }
+
+}
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..25ebd83
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntryPointTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Controller;
+
+use Drupal\Core\Cache\CacheableJsonResponse;
+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'),
+      new CacheableJsonResponse()
+    );
+    $processed_response = $controller->index();
+    $this->assertEquals(
+      ['url.site'],
+      $processed_response->getCacheableMetadata()->getCacheContexts()
+    );
+    $data = json_decode($processed_response->getContent(), TRUE);
+    $links = $data['links'];
+    $this->assertRegExp('/.*\/jsonapi/', $links['self']);
+    $this->assertRegExp('/.*\/jsonapi\/user\/user/', $links['user--user']);
+    $this->assertRegExp('/.*\/jsonapi\/node_type\/node_type/', $links['node_type--node_type']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/EntityToJsonApiTest.php b/core/modules/jsonapi/tests/src/Kernel/EntityToJsonApiTest.php
new file mode 100644
index 0000000..6bc0260
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/EntityToJsonApiTest.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\file\Entity\File;
+use Drupal\jsonapi\LinkManager\LinkManager;
+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\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Drupal\user\RoleInterface;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\EntityToJsonApi
+ * @group jsonapi
+ * @group jsonapi_serializer
+ * @group legacy
+ *
+ * @internal
+ */
+class EntityToJsonApiTest extends JsonapiKernelTestBase {
+
+  use ImageFieldCreationTrait;
+
+  /**
+   * System under test.
+   *
+   * @var \Drupal\jsonapi\EntityToJsonApi
+   */
+  protected $sut;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'jsonapi',
+    'field',
+    'node',
+    'serialization',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+    'file',
+    'image',
+  ];
+
+  /**
+   * {@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']);
+    $this->nodeType = NodeType::create([
+      'type' => 'article',
+    ]);
+    $this->nodeType->save();
+    $this->createEntityReferenceField(
+      'node',
+      'article',
+      'field_tags',
+      'Tags',
+      'taxonomy_term',
+      'default',
+      ['target_bundles' => ['tags']],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+
+    $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,
+      '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');
+
+    $this->role = Role::create([
+      'id' => RoleInterface::ANONYMOUS_ID,
+      'permissions' => [
+        'access content',
+      ],
+    ]);
+    $this->role->save();
+    $this->sut = \Drupal::service('jsonapi.entity.to_jsonapi');
+  }
+
+  /**
+   * @covers ::serialize
+   * @covers ::normalize
+   */
+  public function testSerialize() {
+    $entities = [
+      $this->node,
+      $this->user,
+      $this->file,
+      $this->term1,
+      // Make sure we also support configuration entities.
+      $this->vocabulary,
+      $this->nodeType,
+      $this->role,
+    ];
+    array_walk(
+      $entities,
+      function ($entity) {
+        $output = $this->sut->serialize($entity);
+        $this->assertInternalType('string', $output);
+        $this->assertJsonApi(Json::decode($output));
+        $output = $this->sut->normalize($entity);
+        $this->assertInternalType('array', $output);
+        $this->assertJsonApi($output);
+      }
+    );
+  }
+
+  /**
+   * Helper to assert if a string is valid JSON API.
+   *
+   * @param array $structured
+   *   The JSON API data to check.
+   */
+  protected function assertJsonApi(array $structured) {
+    $this->assertNotEmpty($structured['data']['type']);
+    $this->assertNotEmpty($structured['data']['id']);
+    $this->assertNotEmpty($structured['data']['attributes']);
+    $this->assertInternalType('string', $structured['links']['self']);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php b/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php
new file mode 100644
index 0000000..5056f2c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Field;
+
+use Drupal\file\Entity\File;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Field\FileDownloadUrl
+ * @group jsonapi
+ * @group legacy
+ *
+ * @internal
+ */
+class FileDownloadUrlTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'jsonapi',
+    'file',
+    'serialization',
+    'text',
+    'user',
+  ];
+
+  /**
+   * The test file.
+   *
+   * @var \Drupal\file\Entity\File
+   */
+  protected $file;
+
+  /**
+   * The test filename.
+   *
+   * @var string
+   */
+  protected $filename = 'druplicon.txt';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('file');
+    $this->installSchema('file', ['file_usage']);
+
+    // Create a new file entity.
+    $this->file = File::create([
+      'filename' => $this->filename,
+      'uri' => sprintf('public://%s', $this->filename),
+      'filemime' => 'text/plain',
+      'status' => FILE_STATUS_PERMANENT,
+    ]);
+
+    $this->file->save();
+  }
+
+  /**
+   * Test the URL computed field.
+   */
+  public function testUrlField() {
+    $url_field = $this->file->get('url');
+    // Test all the different ways to access a field item.
+    $values = [
+      $url_field->value,
+      $url_field->getValue()[0]['value'],
+      $url_field->get(0)->toArray()['value'],
+      $url_field->first()->getValue()['value'],
+    ];
+    array_walk($values, function ($value) {
+      $this->assertContains('simpletest', $value);
+      $this->assertContains($this->filename, $value);
+    });
+    $violationList = $this->file->validate();
+    $this->assertEquals(0, $violationList->count());
+  }
+
+}
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..f26bb4e
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+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 = $this->container->get('serializer.normalizer.entity_condition_group.jsonapi');
+
+    $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 = $this->container->get('serializer.normalizer.entity_condition_group.jsonapi');
+    $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..53a84c8
--- /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 = $this->container->get('serializer.normalizer.entity_condition.jsonapi');
+  }
+
+  /**
+   * @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..b45f969
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+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->container->set('jsonapi.field_resolver', $this->getFieldResolver('foo', 'bar'));
+    $this->normalizer = $this->container->get('serializer.normalizer.filter.jsonapi');
+  }
+
+  /**
+   * @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 [
+      [
+        ['uid' => ['value' => 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->resolveInternal('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..ea58901
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,793 @@
+<?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\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\jsonapi\ResourceResponse;
+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\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @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;
+
+  /**
+   * {@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();
+  }
+
+  /**
+   * {@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
+   * @dataProvider normalizeValueProvider
+   */
+  public function testNormalize($include) {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node--article' => 'title,type,uid,field_tags,field_image',
+        'user--user' => 'name',
+      ],
+      'include' => $include,
+    ]);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->getNormalizer()
+      ->normalize(
+        new JsonApiDocumentTopLevel($this->node),
+        'api_json',
+        [
+          'request' => $request,
+          'resource_type' => $resource_type,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+        ]
+      );
+
+    // @see http://jsonapi.org/format/#document-jsonapi-object
+    $this->assertEquals($normalized['jsonapi']['version'], '1.0');
+    $this->assertEquals($normalized['jsonapi']['meta']['links']['self'], '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' => 'dummy_entity_link',
+        'related' => 'dummy_entity_link',
+      ],
+    ], $normalized['data']['relationships']['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' => 'dummy_entity_link',
+        'related' => 'dummy_entity_link',
+      ],
+    ], $normalized['data']['relationships']['uid']);
+    $this->assertEquals(
+      "The current user is not allowed to GET the selected resource. The 'access user profiles' permission is required and the user must be active.",
+      $normalized['meta']['errors'][0]['detail']
+    );
+    $this->assertEquals(403, $normalized['meta']['errors'][0]['status']);
+    $this->assertEquals($this->term1->uuid(), $normalized['included'][0]['id']);
+    $this->assertEquals('taxonomy_term--tags', $normalized['included'][0]['type']);
+    $this->assertEquals($this->term1->label(), $normalized['included'][0]['attributes']['name']);
+    $this->assertTrue(!isset($normalized['included'][0]['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'],
+      $response->getCacheableMetadata()->getCacheTags()
+    );
+    $this->assertSame(
+      Cache::PERMANENT,
+      $response->getCacheableMetadata()->getCacheMaxAge()
+    );
+  }
+
+  /**
+   * Data provider for testNormalize.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function normalizeValueProvider() {
+    return [
+      ['uid,field_tags,field_image'],
+      ['uid, field_tags, field_image'],
+    ];
+  }
+
+  /**
+   * @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);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->getNormalizer()
+      ->normalize(
+        $document_wrapper->reveal(),
+        'api_json',
+        [
+          'request' => $request,
+          'resource_type' => $resource_type,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+        ]
+      );
+    $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'], $response->getCacheableMetadata()
+      ->getCacheTags());
+    $this->assertSame(Cache::PERMANENT, $response->getCacheableMetadata()
+      ->getCacheMaxAge());
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeUuid() {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uuid');
+    $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class);
+    $document_wrapper->getData()->willReturn($this->node);
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node--article' => 'title,type,uid,field_tags',
+        'user--user' => 'name',
+      ],
+      'include' => 'uid,field_tags',
+    ]);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->getNormalizer()
+      ->normalize(
+        $document_wrapper->reveal(),
+        'api_json',
+        [
+          'request' => $request,
+          'resource_type' => $resource_type,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+        ]
+      );
+    $this->assertStringMatchesFormat($this->node->uuid(), $normalized['data']['id']);
+    $this->assertEquals($this->node->type->entity->uuid(), $normalized['data']['relationships']['type']['data']['id']);
+    $this->assertEquals($this->user->uuid(), $normalized['data']['relationships']['uid']['data']['id']);
+    $this->assertFalse(empty($normalized['included'][0]['id']));
+    $this->assertFalse(empty($normalized['meta']['errors']));
+    $this->assertEquals($this->term1->uuid(), $normalized['included'][0]['id']);
+    // 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'],
+      $response->getCacheableMetadata()->getCacheTags()
+    );
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeException() {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id');
+    $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class);
+    $document_wrapper->getData()->willReturn($this->node);
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node--article' => 'title,type,uid',
+        'user--user' => 'name',
+      ],
+      'include' => 'uid',
+    ]);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->container
+      ->get('jsonapi.serializer_do_not_use_removal_imminent')
+      ->serialize(
+        new BadRequestHttpException('Lorem'),
+        'api_json',
+        [
+          'request' => $request,
+          'resource_type' => $resource_type,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+          'data_wrapper' => 'errors',
+        ]
+      );
+    $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' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1'], $normalized['errors'][0]['links']);
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeConfig() {
+    list($request, $resource_type) = $this->generateProphecies('node_type', 'node_type', 'id');
+    $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class);
+    $document_wrapper->getData()->willReturn($this->nodeType);
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node_type--node_type' => 'uuid,display_submitted',
+      ],
+      'include' => NULL,
+    ]);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->getNormalizer()
+      ->normalize($document_wrapper->reveal(), 'api_json', [
+        'request' => $request,
+        'resource_type' => $resource_type,
+        'cacheable_metadata' => $response->getCacheableMetadata(),
+      ]);
+    $this->assertTrue(empty($normalized['data']['attributes']['type']));
+    $this->assertTrue(!empty($normalized['data']['attributes']['uuid']));
+    $this->assertSame($normalized['data']['attributes']['display_submitted'], TRUE);
+    $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'], $response->getCacheableMetadata()
+      ->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', [
+        'request' => $request,
+        '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(),
+        ],
+      ],
+      // Bad data in user and first tag.
+      [
+        [
+          ['invalid-uuid', $this->term1->uuid()],
+          'also-invalid-uuid',
+        ],
+        [
+          [$this->term1->id()],
+          NULL,
+        ],
+      ],
+    ];
+
+    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);
+      $node = $this
+        ->getNormalizer()
+        ->denormalize(Json::decode($payload), NULL, 'api_json', [
+          'request' => $request,
+          'resource_type' => $resource_type,
+        ]);
+
+      /* @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', [
+          'request' => $request,
+          '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', [
+          'request' => $request,
+          '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',
+            'id' => '33095485-70D2-4E51-A309-535CC5BC0115',
+          ],
+          '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');
+    $actual_metadata = new CacheableMetadata();
+    $context = [
+      'request' => $this->decorateRequest($request, $fields, $includes),
+      'resource_type' => $resource_type,
+      'cacheable_metadata' => $actual_metadata,
+    ];
+    $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel($this->node), 'api_json', $context);
+    $this->assertArraySubset($expected_metadata->getCacheTags(), $actual_metadata->getCacheTags());
+    $this->assertArraySubset($expected_metadata->getCacheContexts(), $actual_metadata->getCacheContexts());
+    $this->assertSame($expected_metadata->getCacheMaxAge(), $actual_metadata->getCacheMaxAge());
+  }
+
+  /**
+   * Provides test cases for asserting cacheable metadata behavior.
+   */
+  public function testCacheableMetadataProvider() {
+    $cacheable_metadata = function ($metadata) {
+      return CacheableMetadata::createFromRenderArray(['#cache' => $metadata]);
+    };
+
+    return [
+      [
+        floatval(\Drupal::VERSION) < 8.5
+        ? $cacheable_metadata([])
+        : $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() {
+    return $this->container->get('jsonapi_test_normalizers_kernel.jsonapi_document_toplevel');
+  }
+
+  /**
+   * 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.
+   * @param string $related_property
+   *   The related property.
+   *
+   * @return array
+   *   A numeric array containing the request and the ResourceType.
+   */
+  protected function generateProphecies($entity_type_id, $bundle, $related_property = NULL) {
+    $path = sprintf('/%s/%s', $entity_type_id, $bundle);
+    $path = $related_property ?
+      sprintf('%s/%s', $path, $related_property) :
+      $path;
+
+    $route = new Route($path, [
+      '_on_relationship' => NULL,
+    ], [
+      '_entity_type' => $entity_type_id,
+      '_bundle' => $bundle,
+    ]);
+    $request = new Request([], [], [
+      RouteObjectInterface::ROUTE_OBJECT => $route,
+    ]);
+    /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
+    $entity_type_manager = $this->container->get('entity_type.manager');
+
+    $resource_type = new ResourceType(
+      $entity_type_id,
+      $bundle,
+      $entity_type_manager->getDefinition($entity_type_id)->getClass()
+    );
+
+    /* @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */
+    $request_stack = $this->container->get('request_stack');
+    $request_stack->push($request);
+    $this->container->set('request_stack', $request_stack);
+    $this->container->get('jsonapi.serializer_do_not_use_removal_imminent');
+
+    return [$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..c22e89f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+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 = $this->container->get('serializer.normalizer.offset_page.jsonapi');
+  }
+
+  /**
+   * @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..7a63914
--- /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\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->container->set('jsonapi.field_resolver', $this->getFieldResolver('foo', 'bar'));
+    $this->normalizer = $this->container->get('serializer.normalizer.sort.jsonapi');
+  }
+
+  /**
+   * @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->resolveInternal('foo', 'bar', 'lorem')->willReturn('foo');
+    $field_resolver->resolveInternal('foo', 'bar', 'ipsum')->willReturn('bar');
+    $field_resolver->resolveInternal('foo', 'bar', 'dolor')->willReturn('baz');
+    $field_resolver->resolveInternal('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..3c93d6a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Query/FilterTest.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Query;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\Filter
+ * @group jsonapi
+ * @group jsonapi_query
+ * @group legacy
+ *
+ * @internal
+ */
+class FilterTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    '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 = $this->container->get('serializer.normalizer.filter.jsonapi');
+    $this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node');
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testQueryCondition() {
+    // Can't use a data provider because we need access to the container.
+    $data = $this->queryConditionData();
+
+    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);
+
+      // 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');
+    $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' => 'red',
+              'operator' => 'CONTAINS',
+              'memberOf' => 'nested-or-group',
+            ],
+          ],
+          'condition-1' => [
+            'condition' => [
+              'path' => 'shapes',
+              'value' => 'circle',
+              'operator' => 'CONTAINS',
+              'memberOf' => 'nested-or-group',
+            ],
+          ],
+          'condition-2' => [
+            'condition' => [
+              'path' => 'colors',
+              'value' => 'yellow',
+              'operator' =>
+              'CONTAINS',
+              'memberOf' => 'nested-and-group',
+            ],
+          ],
+          'condition-3' => [
+            'condition' => [
+              'path' => 'shapes',
+              'value' => 'square',
+              'operator' => 'CONTAINS',
+              '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
+    );
+  }
+
+  /**
+   * 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..d77034d
--- /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..950a5a1
--- /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..5bfbaae
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Serializer;
+
+use Drupal\Core\Render\Markup;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+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',
+  ];
+
+  /**
+   * 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->sut = $this->container->get('jsonapi.serializer_do_not_use_removal_imminent');
+  }
+
+  /**
+   * @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 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/Unit/Access/CustomQueryParameterNamesAccessCheckTest.php b/core/modules/jsonapi/tests/src/Unit/Access/CustomQueryParameterNamesAccessCheckTest.php
new file mode 100644
index 0000000..6c32e57
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Access/CustomQueryParameterNamesAccessCheckTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Access;
+
+use Drupal\jsonapi\Access\CustomQueryParameterNamesAccessCheck;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Access\CustomQueryParameterNamesAccessCheck
+ * @group jsonapi
+ *
+ * @internal
+ */
+class CustomQueryParameterNamesAccessCheckTest extends UnitTestCase {
+
+  /**
+   * Ensures that query params are properly validated.
+   *
+   * @dataProvider providerTestAccess
+   * @covers ::access
+   * @covers ::validate
+   */
+  public function testAccess($name, $valid) {
+    $access_checker = new CustomQueryParameterNamesAccessCheck();
+
+    $request = new Request();
+    $request->attributes->set('_json_api_params', [$name => '123']);
+    $result = $access_checker->access($request);
+
+    if ($valid) {
+      $this->assertTrue($result->isAllowed());
+    }
+    else {
+      $this->assertFalse($result->isAllowed());
+    }
+  }
+
+  /**
+   * Data provider for testAccess.
+   */
+  public function providerTestAccess() {
+    $data = [];
+
+    $data['Official query parameter: sort'] = ['sort', TRUE];
+    $data['Official query parameter: page'] = ['page', TRUE];
+    $data['Official query parameter: filter'] = ['filter', TRUE];
+
+    $data['Valid member, but invalid custom query parameter'] = ['foobar', FALSE];
+
+    $data['Valid custom query parameter: dash'] = ['foo-bar', TRUE];
+    $data['Valid custom query parameter: underscore'] = ['foo_bar', TRUE];
+    $data['Valid custom query parameter: camelcase'] = ['fooBar', TRUE];
+
+    return $data;
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php b/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php
new file mode 100644
index 0000000..c806e40
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Context;
+
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\node\NodeInterface;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Context\CurrentContext
+ * @group jsonapi
+ *
+ * @internal
+ */
+class CurrentContextTest extends UnitTestCase {
+
+  /**
+   * A mock for the current route.
+   *
+   * @var \Symfony\Component\Routing\Route
+   */
+  protected $currentRoute;
+
+  /**
+   * A mock for the JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * A request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The route matcher service.
+   *
+   * @var \Drupal\Core\Routing\StackedRouteMatchInterface
+   */
+  protected $routeMatcher;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    // Create a mock for the entity field manager.
+    $this->fieldManager = $this->prophesize(EntityFieldManagerInterface::class)->reveal();
+
+    // Create a mock for the current route match.
+    $this->currentRoute = new Route(
+      '/jsonapi/articles',
+      [],
+      ['_entity_type' => 'node', '_bundle' => 'article']
+    );
+
+    // Create a mock for the ResourceTypeRepository service.
+    $resource_type_repository_prophecy = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository_prophecy->get('node', 'article')
+      ->willReturn(new ResourceType('node', 'article', NodeInterface::class));
+    $this->resourceTypeRepository = $resource_type_repository_prophecy->reveal();
+
+    $this->requestStack = new RequestStack();
+    $this->requestStack->push(new Request([], [], [
+      '_json_api_params' => [
+        'filter' => new Filter(new EntityConditionGroup('AND', [])),
+        'sort' => new Sort([]),
+        'page' => new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX),
+        // 'include' => new IncludeParam([]),
+        // 'fields' => new Fields([]),.
+      ],
+      RouteObjectInterface::ROUTE_OBJECT => $this->currentRoute,
+    ]));
+
+    $this->routeMatcher = new CurrentRouteMatch($this->requestStack);
+  }
+
+  /**
+   * @covers ::getResourceType
+   */
+  public function testGetResourceType() {
+    $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
+
+    $this->assertEquals(
+      $this->resourceTypeRepository->get('node', 'article'),
+      $request_context->getResourceType()
+    );
+  }
+
+  /**
+   * @covers ::getJsonApiParameter
+   */
+  public function testGetJsonApiParameter() {
+    $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
+
+    $actual = $request_context->getJsonApiParameter('sort');
+
+    $this->assertTrue($actual instanceof Sort);
+  }
+
+  /**
+   * @covers ::hasExtension
+   */
+  public function testHasExtensionWithExistingExtension() {
+    $request = new Request();
+    $request->headers->set('Content-Type', 'application/vnd.api+json; ext="ext1,ext2"');
+    $this->requestStack->push($request);
+    $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
+
+    $this->assertTrue($request_context->hasExtension('ext1'));
+    $this->assertTrue($request_context->hasExtension('ext2'));
+  }
+
+  /**
+   * @covers ::getExtensions
+   */
+  public function testGetExtensions() {
+    $request = new Request();
+    $request->headers->set('Content-Type', 'application/vnd.api+json; ext="ext1,ext2"');
+    $this->requestStack->push($request);
+    $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
+
+    $this->assertEquals(['ext1', 'ext2'], $request_context->getExtensions());
+  }
+
+  /**
+   * @covers ::hasExtension
+   */
+  public function testHasExtensionWithNotExistingExtension() {
+    $request = new Request();
+    $request->headers->set('Content-Type', 'application/vnd.api+json;');
+    $this->requestStack->push($request);
+    $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
+    $this->assertFalse($request_context->hasExtension('ext1'));
+    $this->assertFalse($request_context->hasExtension('ext2'));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php b/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php
new file mode 100644
index 0000000..442b580
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Controller;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Controller\RequestHandler;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Controller\RequestHandler
+ * @group jsonapi
+ *
+ * @internal
+ */
+class RequestHandlerTest extends UnitTestCase {
+
+  /**
+   * @covers ::deserializeBody
+   */
+  public function testDeserializeBodyFail() {
+    $request = $this->prophesize(Request::class);
+    $request->getContentType()->willReturn(NULL);
+    $request->getContent()->willReturn('this is not used');
+    $request->isMethodCacheable()->willReturn(FALSE);
+    $request->getMethod()->willReturn(NULL);
+    $request->get(Argument::any())->willReturn(NULL);
+    $request->getMimeType(Argument::any())->willReturn(NULL);
+    $serializer = $this->prophesize(SerializerInterface::class);
+    $serializer->deserialize(Argument::type('string'), Argument::type('string'), Argument::any(), Argument::type('array'))
+      ->willThrow(new UnexpectedValueException('Foo'));
+    $serializer->serialize(Argument::any(), Argument::any(), Argument::any())
+      ->willReturn('{"errors":[{"status":422,"message":"Foo"}]}');
+    $current_context = $this->prophesize(CurrentContext::class);
+    $current_context->getResourceType()
+      ->willReturn(new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL));
+
+    $request_handler = new RequestHandler(
+      $serializer->reveal(),
+      $current_context->reveal(),
+      $this->prophesize(RendererInterface::class)->reveal(),
+      $this->prophesize(ResourceTypeRepositoryInterface::class)->reveal(),
+      $this->prophesize(EntityTypeManagerInterface::class)->reveal(),
+      $this->prophesize(EntityFieldManagerInterface::class)->reveal(),
+      $this->prophesize(FieldTypePluginManagerInterface::class)->reveal(),
+      $this->prophesize(LinkManager::class)->reveal()
+    );
+
+    try {
+      $this->setExpectedException(HttpException::class, "There was an error un-serializing the data. Message: Foo");
+      $request_handler->deserializeBody(
+        $request->reveal(),
+        'invalid'
+      );
+      $this->fail('Expected exception.');
+    }
+    catch (HttpException $e) {
+      $this->assertEquals(422, $e->getStatusCode());
+      // Re-throw the exception so the test runner can catch it.
+      throw $e;
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php b/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php
new file mode 100644
index 0000000..a4a628d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\EventSubscriber;
+
+use JsonSchema\Validator;
+use Drupal\Core\Extension\Extension;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber;
+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\Routing\Route;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber
+ * @group jsonapi
+ *
+ * @internal
+ */
+class ResourceResponseSubscriberTest 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());
+    $subscriber = new ResourceResponseSubscriber(
+      new Serializer([], [new JsonSchemaEncoder()]),
+      $this->prophesize(RendererInterface::class)->reveal(),
+      $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',
+      '/jsonapi/node/article/{node}',
+      ['_entity_type' => 'node', '_bundle' => 'article']
+    );
+
+    $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
+   */
+  public function testValidateResponseSchemata() {
+    $request = $this->createRequest(
+      'jsonapi.node--article.individual',
+      '/jsonapi/node/article/{node}',
+      ['_entity_type' => 'node', '_bundle' => 'article']
+    );
+
+    $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',
+      '/jsonapi/node/article/{node}/foo',
+      ['_entity_type' => 'node', '_bundle' => 'article']
+    );
+
+    // 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',
+      '/jsonapi/node/article/{node}/relationships/foo',
+      ['_entity_type' => 'node', '_bundle' => 'article']
+    );
+
+    // 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',
+      'route' => '/jsonapi/node/article/{node}',
+      'requirements' => ['_entity_type' => 'node', '_bundle' => 'article'],
+    ];
+
+    $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, $route, $requirements) = array_values($input + $defaults);
+      return [
+        $this->createRequest($route_name, $route, $requirements),
+        $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 string $route
+   *   The route object with which to construct a request.
+   * @param array $requirements
+   *   The route requirements.
+   *
+   * @return \Symfony\Component\HttpFoundation\Request
+   *   The mock request object.
+   */
+  protected function createRequest($route_name, $route, array $requirements = []) {
+    $request = new Request();
+    $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name);
+    $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, (new Route($route))->setRequirements($requirements));
+    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..99c2616
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\LinkManager;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+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\Cmf\Component\Routing\ChainRouterInterface;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @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();
+    $router = $this->prophesize(ChainRouterInterface::class);
+    $url_generator = $this->prophesize(UrlGeneratorInterface::class);
+    $url_generator->generateFromRoute(Argument::cetera())->willReturnArgument(2);
+    $this->linkManager = new LinkManager($router->reveal(), $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']);
+      });
+
+    $container = new ContainerBuilder();
+    $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 '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(BadRequestHttpException::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..3d1aab1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+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);
+
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository->get(Argument::type('string'), Argument::type('string'))
+      ->willReturn(new ResourceType('dolor', 'sid', NULL));
+
+    $this->normalizer = new ConfigEntityNormalizer(
+      $link_manager->reveal(),
+      $resource_type_repository->reveal(),
+      $this->prophesize(EntityTypeManagerInterface::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/EntityReferenceFieldNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php
new file mode 100644
index 0000000..0319cdd
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php
@@ -0,0 +1,190 @@
+<?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\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class EntityReferenceFieldNormalizerTest extends UnitTestCase {
+
+  /**
+   * The normalizer under test.
+   *
+   * @var \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    $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(new ResourceType('lorem', 'dummy_bundle', NULL));
+
+    $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 EntityReferenceFieldNormalizer(
+      $link_manager->reveal(),
+      $field_manager->reveal(),
+      $plugin_manager->reveal(),
+      $resource_type_repository->reveal(),
+      $entity_repository->reveal()
+    );
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($input, $field_name, $expected) {
+    $entity = $this->prophesize(FieldableEntityInterface::class);
+    $context = [
+      'resource_type' => new ResourceType('fake_entity_type', 'dummy_bundle', NULL),
+      'related' => $field_name,
+      'target_entity' => $entity->reveal(),
+    ];
+    $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--dummy_bundle', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']]],
+        'field_dummy',
+        [['bunny' => 42]],
+      ],
+      [
+        ['data' => []],
+        'field_dummy',
+        [],
+      ],
+      [
+        ['data' => NULL],
+        'field_dummy_single',
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeInvalidResourceProvider
+   */
+  public function testDenormalizeInvalidResource($data, $field_name) {
+    $context = [
+      'resource_type' => new ResourceType('fake_entity_type', 'dummy_bundle', NULL),
+      '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/HttpExceptionNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
new file mode 100644
index 0000000..c3ea66d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+ * @group jsonapi
+ *
+ * @internal
+ */
+class HttpExceptionNormalizerTest extends UnitTestCase {
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalize() {
+    $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..1c32d87
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,267 @@
+<?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\Exception\EntityAccessDeniedHttpException;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\Routing\Route;
+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);
+    $current_context_manager = $this->prophesize(CurrentContext::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());
+
+    $current_route = $this->prophesize(Route::class);
+    $current_route->getDefault('_on_relationship')->willReturn(FALSE);
+
+    $current_context_manager->isOnRelationship()->willReturn(FALSE);
+
+    $this->normalizer = new JsonApiDocumentTopLevelNormalizer(
+      $link_manager->reveal(),
+      $current_context_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) {
+    $context = [
+      'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), FieldableEntityInterface::class),
+    ];
+    $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(
+        EntityAccessDeniedHttpException::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(
+        $this->randomMachineName(),
+        $this->randomMachineName(),
+        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/Value/EntityNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
new file mode 100644
index 0000000..e184f02
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
@@ -0,0 +1,197 @@
+<?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->getIncludes()->willReturn([]);
+    $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]->getIncludes()->willReturn([]);
+    $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]->getIncludes()->willReturn([]);
+    $included[1]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
+        'attributes' => ['body' => 'dummy_body2'],
+      ],
+    ]);
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[2]->getIncludes()->willReturn([]);
+    $included[2]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5',
+        'attributes' => ['body' => 'dummy_body3'],
+      ],
+    ]);
+    $field2->getIncludes()->willReturn(array_map(function ($included_item) {
+      return $included_item->reveal();
+    }, $included));
+    $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' => 'dummy_entity_link',
+      ],
+    ], $this->object->rasterizeValue());
+  }
+
+  /**
+   * @covers ::rasterizeIncludes
+   */
+  public function testRasterizeIncludes() {
+    $expected = [
+      [
+        'data' => [
+          'type' => 'node',
+          'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
+          'attributes' => ['body' => 'dummy_body1'],
+        ],
+      ],
+      [
+        'data' => [
+          'type' => 'node',
+          'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
+          'attributes' => ['body' => 'dummy_body2'],
+        ],
+      ],
+      [
+        'data' => [
+          'type' => 'node',
+          'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5',
+          'attributes' => ['body' => 'dummy_body3'],
+        ],
+      ],
+    ];
+    $this->assertEquals($expected, $this->object->rasterizeIncludes());
+  }
+
+  /**
+   * @covers ::getIncludes
+   */
+  public function testGetIncludes() {
+    $includes = $this->object->getIncludes();
+    $includes = array_filter($includes, function ($included) {
+      return $included instanceof JsonApiDocumentTopLevelNormalizerValue;
+    });
+    $this->assertCount(3, $includes);
+  }
+
+}
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..e5dac5f
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php
@@ -0,0 +1,94 @@
+<?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\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+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],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::rasterizeIncludes
+   */
+  public function testRasterizeIncludes() {
+    $value = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $include = $this->prophesize('\Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue');
+    $include->rasterizeValue()->willReturn('Lorem');
+    $value->getCacheContexts()->willReturn(['ccfoo']);
+    $value->getCacheTags()->willReturn(['ctfoo']);
+    $value->getCacheMaxAge()->willReturn(15);
+    $value->getInclude()->willReturn($include->reveal());
+    $object = new FieldNormalizerValue(AccessResult::allowed(), [$value->reveal()], 1, 'attributes');
+    $this->assertEquals(['Lorem'], $object->rasterizeIncludes());
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php
new file mode 100644
index 0000000..0d184aa
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\Component\DependencyInjection\Container;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+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\JsonApiDocumentTopLevelNormalizerValue
+ * @group jsonapi
+ *
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The JsonApiDocumentTopLevelNormalizerValue object.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue
+   */
+  protected $object;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $cache_contexts_manager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $cache_contexts_manager->method('assertValidTokens')->willReturn(TRUE);
+    $container = new Container();
+    $container->set('cache_contexts_manager', $cache_contexts_manager);
+    \Drupal::setContainer($container);
+
+    $field1 = $this->prophesize(FieldNormalizerValueInterface::class);
+    $field1->getIncludes()->willReturn([]);
+    $field1->getPropertyType()->willReturn('attributes');
+    $field1->rasterizeValue()->willReturn('dummy_title');
+    $field2 = $this->prophesize(RelationshipNormalizerValue::class);
+    $field2->getPropertyType()->willReturn('relationships');
+    $field2->rasterizeValue()->willReturn(['data' => ['type' => 'node', 'id' => 2]]);
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[0]->getIncludes()->willReturn([]);
+    $included[0]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => 3,
+        'attributes' => ['body' => 'dummy_body1'],
+      ],
+    ]);
+    $included[0]->getCacheContexts()->willReturn(['lorem:ipsum']);
+    // Type & id duplicated in purpose.
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[1]->getIncludes()->willReturn([]);
+    $included[1]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => 3,
+        'attributes' => ['body' => 'dummy_body2'],
+      ],
+    ]);
+    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
+    $included[2]->getIncludes()->willReturn([]);
+    $included[2]->rasterizeValue()->willReturn([
+      'data' => [
+        'type' => 'node',
+        'id' => 4,
+        'attributes' => ['body' => 'dummy_body3'],
+      ],
+    ]);
+    $field2->getIncludes()->willReturn(array_map(function ($included_item) {
+      return $included_item->reveal();
+    }, $included));
+    $context = [
+      'resource_type' => new ResourceType('node', 'article', NodeInterface::class),
+    ];
+    $entity = $this->prophesize(EntityInterface::class);
+    $entity->id()->willReturn(1);
+    $entity->isNew()->willReturn(FALSE);
+    $entity->getEntityTypeId()->willReturn('node');
+    $entity->bundle()->willReturn('article');
+    $entity->hasLinkTemplate(Argument::type('string'))->willReturn(TRUE);
+    $url = $this->prophesize(Url::class);
+    $url->toString()->willReturn('dummy_entity_link');
+    $url->setRouteParameter(Argument::any(), Argument::any())->willReturn($url->reveal());
+    $entity->toUrl(Argument::type('string'), Argument::type('array'))->willReturn($url->reveal());
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $this->object = $this->getMockBuilder(JsonApiDocumentTopLevelNormalizerValue::class)
+      ->setMethods(['addCacheableDependency'])
+      ->setConstructorArgs([
+        ['title' => $field1->reveal(), 'field_related' => $field2->reveal()],
+        $context,
+        ['link_manager' => $link_manager->reveal()],
+        $entity->reveal(),
+      ])
+      ->getMock();
+    $this->object->method('addCacheableDependency');
+  }
+
+  /**
+   * @covers ::getIncludes
+   */
+  public function testGetIncludes() {
+    $includes = $this->object->getIncludes();
+    $includes = array_filter($includes, function ($included) {
+      return $included instanceof JsonApiDocumentTopLevelNormalizerValue;
+    });
+    $this->assertCount(2, $includes);
+  }
+
+}
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..8f400c1
--- /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), 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..27e4675
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php
@@ -0,0 +1,138 @@
+<?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->getInclude()->willReturn(NULL);
+    $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->getInclude()->willReturn(NULL);
+    $uid2->getCacheContexts()->willReturn(['ccbar']);
+    $uid2->getCacheTags()->willReturn(['ctbar']);
+    $uid2->getCacheMaxAge()->willReturn(10);
+    $links = [
+      'self' => 'dummy_entity_link',
+      'related' => 'dummy_entity_link',
+    ];
+    return [
+      [[$uid1->reveal()], 1, [
+        'data' => ['type' => 'user', 'id' => 1],
+        'links' => $links,
+      ],
+        (new CacheableMetadata())
+          ->setCacheContexts(['ccfoo', 'user'])
+          ->setCacheTags(['ctfoo', 'relationship:foo'])
+          ->setCacheMaxAge(15),
+      ],
+      [
+        [$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),
+      ],
+    ];
+  }
+
+  /**
+   * @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/JsonApiParamEnhancerTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
new file mode 100644
index 0000000..c7c4972
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing;
+
+use Drupal\jsonapi\Routing\JsonApiParamEnhancer;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Routing\Routes;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Prophecy\Promise\ReturnPromise;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\JsonApiParamEnhancer
+ * @group jsonapi
+ * @group jsonapi_param_enhancer
+ * @group legacy
+ *
+ * @internal
+ */
+class JsonApiParamEnhancerTest extends UnitTestCase {
+
+  /**
+   * @covers ::applies
+   */
+  public function testApplies() {
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
+    $route = $this->prophesize(Route::class);
+    $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)->will(new ReturnPromise([Routes::FRONT_CONTROLLER, 'lorem']));
+
+    $this->assertTrue($object->applies($route->reveal()));
+    $this->assertFalse($object->applies($route->reveal()));
+  }
+
+  /**
+   * @covers ::enhance
+   */
+  public function testEnhanceFilter() {
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
+    $request = $this->prophesize(Request::class);
+    $query = $this->prophesize(ParameterBag::class);
+    $query->get('filter')->willReturn(['filed1' => 'lorem']);
+    $query->has(Argument::type('string'))->willReturn(FALSE);
+    $query->has('filter')->willReturn(TRUE);
+    $request->query = $query->reveal();
+
+    $route = $this->prophesize(Route::class);
+    $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
+    $defaults = $object->enhance([
+      RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
+    ], $request->reveal());
+    $this->assertInstanceOf(Filter::class, $defaults['_json_api_params']['filter']);
+    $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']);
+    $this->assertTrue(empty($defaults['_json_api_params']['sort']));
+  }
+
+  /**
+   * @covers ::enhance
+   */
+  public function testEnhancePage() {
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
+    $request = $this->prophesize(Request::class);
+    $query = $this->prophesize(ParameterBag::class);
+    $query->get('page')->willReturn(['cursor' => 'lorem']);
+    $query->has(Argument::type('string'))->willReturn(FALSE);
+    $query->has('page')->willReturn(TRUE);
+    $request->query = $query->reveal();
+
+    $route = $this->prophesize(Route::class);
+    $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
+    $defaults = $object->enhance([
+      RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
+    ], $request->reveal());
+    $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']);
+    $this->assertTrue(empty($defaults['_json_api_params']['filter']));
+    $this->assertTrue(empty($defaults['_json_api_params']['sort']));
+  }
+
+  /**
+   * @covers ::enhance
+   */
+  public function testEnhanceSort() {
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
+    $request = $this->prophesize(Request::class);
+    $query = $this->prophesize(ParameterBag::class);
+    $query->get('sort')->willReturn('-lorem');
+    $query->has(Argument::type('string'))->willReturn(FALSE);
+    $query->has('sort')->willReturn(TRUE);
+    $request->query = $query->reveal();
+
+    $route = $this->prophesize(Route::class);
+    $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
+    $defaults = $object->enhance([
+      RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
+    ], $request->reveal());
+    $this->assertInstanceOf(Sort::class, $defaults['_json_api_params']['sort']);
+    $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']);
+    $this->assertTrue(empty($defaults['_json_api_params']['filter']));
+  }
+
+  /**
+   * Builds mock normalizers.
+   */
+  public function getMockNormalizers() {
+    $filter_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $filter_normalizer->denormalize(
+      Argument::any(),
+      Filter::class,
+      Argument::any(),
+      Argument::any()
+    )->willReturn($this->prophesize(Filter::class)->reveal());
+
+    $sort_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $sort_normalizer->denormalize(
+      Argument::any(),
+      Sort::class,
+      Argument::any(),
+      Argument::any()
+    )->willReturn($this->prophesize(Sort::class)->reveal());
+
+    $page_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $page_normalizer->denormalize(Argument::any(), OffsetPage::class)->willReturn($this->prophesize(OffsetPage::class)->reveal());
+
+    return [
+      $filter_normalizer->reveal(),
+      $sort_normalizer->reveal(),
+      $page_normalizer->reveal(),
+    ];
+  }
+
+}
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..67f3c1a
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing;
+
+use Drupal\Core\Authentication\AuthenticationCollectorInterface;
+use Drupal\Core\Entity\EntityInterface;
+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]);
+    $resource_type_repository->getPathPrefix()->willReturn('jsonapi');
+    $container = $this->prophesize(ContainerInterface::class);
+    $container->get('jsonapi.resource_type.repository')->willReturn($resource_type_repository->reveal());
+    $auth_collector = $this->prophesize(AuthenticationCollectorInterface::class);
+    $auth_collector->getSortedProviders()->willReturn([
+      'lorem' => [],
+      'ipsum' => [],
+    ]);
+    $container->get('authentication_collector')->willReturn($auth_collector->reveal());
+
+    $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();
+
+    // Make sure that there are 4 routes for the non-internal resource.
+    $this->assertEquals(4, $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('entity_type_1', $route->getRequirement('_entity_type'));
+    $this->assertSame('bundle_1_1', $route->getRequirement('_bundle'));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals(['GET', 'POST'], $route->getMethods());
+    $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame('Drupal\jsonapi\Resource\JsonApiDocumentTopLevel', $route->getOption('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_type_1}', $route->getPath());
+    $this->assertSame('entity_type_1', $route->getRequirement('_entity_type'));
+    $this->assertSame('bundle_1_1', $route->getRequirement('_bundle'));
+    $this->assertEquals(['GET', 'PATCH', 'DELETE'], $route->getMethods());
+    $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame('Drupal\jsonapi\Resource\JsonApiDocumentTopLevel', $route->getOption('serialization_class'));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $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.related');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity_type_1}/{related}', $route->getPath());
+    $this->assertSame('entity_type_1', $route->getRequirement('_entity_type'));
+    $this->assertSame('bundle_1_1', $route->getRequirement('_bundle'));
+    $this->assertEquals(['GET'], $route->getMethods());
+    $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $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.relationship');
+    $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity_type_1}/relationships/{related}', $route->getPath());
+    $this->assertSame('entity_type_1', $route->getRequirement('_entity_type'));
+    $this->assertSame('bundle_1_1', $route->getRequirement('_bundle'));
+    $this->assertEquals(['GET', 'POST', 'PATCH', 'DELETE'], $route->getMethods());
+    $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME));
+    $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth'));
+    $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $route->getOption('parameters'));
+    $this->assertSame('Drupal\Core\Field\EntityReferenceFieldItemList', $route->getOption('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.related'],
+      ['jsonapi.entity_type_1--bundle_1_1.relationship'],
+    ];
+  }
+
+  /**
+   * 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.related'],
+      ['jsonapi.entity_type_2--bundle_2_1.relationship'],
+    ];
+  }
+
+}
