diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index c5c22d3..af03305 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -243,6 +243,9 @@ Installer
 Interface Translation (locale)
 - Gábor Hojtsy 'Gábor Hojtsy' https://www.drupal.org/u/gábor-hojtsy
 
+JSON-API
+- Mateu Aguiló Bosch 'e0ipso' https://www.drupal.org/u/e0ipso
+
 JavaScript
 - Théodore Biadala 'nod_' https://www.drupal.org/u/nod_
 - Kay Leung 'droplet' https://www.drupal.org/u/droplet
diff --git a/core/composer.json b/core/composer.json
index 71f07d0..4efe97e 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -104,6 +104,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_discovery": "self.version",
         "drupal/link": "self.version",
diff --git a/core/modules/jsonapi/README.md b/core/modules/jsonapi/README.md
new file mode 100644
index 0000000..ab01a11
--- /dev/null
+++ b/core/modules/jsonapi/README.md
@@ -0,0 +1,24 @@
+# JSON API
+The jsonapi module exposes a [JSON API](http://jsonapi.org/) implementation for data stored in Drupal.
+
+## Installation
+
+Install the module as every other module.
+
+## Compatibility
+
+This module is compatible with Drupal core 8.2.x and higher.
+
+## Configuration
+
+Unlike the core REST module JSON API doesn't really require any kind of configuration by default.
+
+## Usage
+
+The jsonapi module exposes both config and content entity resources. On top of that it exposes one resource per bundle per entity. The default format appears like: `/jsonapi/{entity_type}/{bundle}/{uuid}?_format=api_json`
+
+The list of endpoints then looks like the following:
+* `/jsonapi/node/article?_format=api_json`: Exposes a collection of article content
+* `/jsonapi/node/article/{UUID}?_format=api_json`: Exposes an individual article
+* `/jsonapi/block?_format=api_json`: Exposes a collection of blocks
+* `/jsonapi/block/{block}?_format=api_json`: Exposes an individual block
diff --git a/core/modules/jsonapi/jsonapi.info.yml b/core/modules/jsonapi/jsonapi.info.yml
new file mode 100644
index 0000000..36bb15c
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.info.yml
@@ -0,0 +1,8 @@
+name: JSON API
+type: module
+description: Provides a JSON API format for the REST resources.
+core: 8.x
+package: Web services
+dependencies:
+  - drupal:system (>=8.2)
+  - serialization
diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module
new file mode 100644
index 0000000..df5bc5c
--- /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\Url;
+use Drupal\Core\Link;
+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('uri')
+      ->setLabel(t('Download URL'))
+      ->setDescription(t('The download URL of the file.'))
+      ->setComputed(TRUE)
+      ->setQueryable(FALSE)
+      ->setClass('\Drupal\jsonapi\Field\FileDownloadUrl')
+      ->setDisplayOptions('view', array(
+        'label' => 'above',
+        'weight' => -5,
+      ));
+  }
+  return $fields;
+}
diff --git a/core/modules/jsonapi/jsonapi.routing.yml b/core/modules/jsonapi/jsonapi.routing.yml
new file mode 100644
index 0000000..9a9ab7d
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.routing.yml
@@ -0,0 +1,2 @@
+route_callbacks:
+  - '\Drupal\jsonapi\Routing\Routes::routes'
\ No newline at end of file
diff --git a/core/modules/jsonapi/jsonapi.services.yml b/core/modules/jsonapi/jsonapi.services.yml
new file mode 100644
index 0000000..f42e648
--- /dev/null
+++ b/core/modules/jsonapi/jsonapi.services.yml
@@ -0,0 +1,98 @@
+services:
+  serializer.normalizer.htt_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      - { name: normalizer, priority: 1 }
+  serializer.normalizer.unprocessable_entity_exception.jsonapi:
+    class: Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
+    arguments: ['@current_user']
+    tags:
+      - { name: normalizer, priority: 2 }
+  serializer.normalizer.scalar.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ScalarNormalizer
+    tags:
+      - { name: normalizer, priority: 5 }
+  serializer.normalizer.entity_reference_item.jsonapi:
+    class: Drupal\jsonapi\Normalizer\RelationshipItemNormalizer
+    arguments: ['@jsonapi.resource_type.repository', '@serializer.normalizer.jsonapi_document_toplevel.jsonapi',]
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.field_item.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldItemNormalizer
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.field.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FieldNormalizer
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.relationship.jsonapi:
+    class: Drupal\jsonapi\Normalizer\RelationshipNormalizer
+    arguments: ['@jsonapi.resource_type.repository', '@jsonapi.link_manager']
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager']
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.config_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager']
+    tags:
+      - { name: normalizer, priority: 21 }
+  serializer.normalizer.jsonapi_document_toplevel.jsonapi:
+    class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+    arguments: ['@jsonapi.link_manager', '@jsonapi.current_context', '@entity_type.manager']
+    tags:
+      - { name: normalizer, priority: 22 }
+  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:
+      - { name: normalizer, priority: 31 }
+  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']
+  jsonapi.route_enhancer:
+    class: Drupal\jsonapi\Routing\RouteEnhancer
+    tags:
+      - { name: route_enhancer }
+  jsonapi.params.enhancer:
+    class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
+    arguments: ['@entity_field.manager']
+    tags:
+      - { name: route_enhancer }
+  jsonapi.query_builder:
+    class: Drupal\jsonapi\Query\QueryBuilder
+    arguments: ['@entity_type.manager', '@jsonapi.current_context', '@jsonapi.field_resolver']
+  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_field.manager']
+  access_check.jsonapi.custom_parameter_names:
+    class: Drupal\jsonapi\Access\CustomParameterNames
+    tags:
+      - { name: access_check, applies_to: _custom_parameter_names }
+  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.error_handler:
+    class: Drupal\jsonapi\Error\ErrorHandler
+  jsonapi.exception_subscriber:
+    class: Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@serializer', '%serializer.formats%']
diff --git a/core/modules/jsonapi/phpcs.xml b/core/modules/jsonapi/phpcs.xml
new file mode 100644
index 0000000..686fca8
--- /dev/null
+++ b/core/modules/jsonapi/phpcs.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ruleset name="jsonapi">
+  <description>Default PHP CodeSniffer configuration for RESTful.</description>
+  <file>.</file>
+  <arg name="extensions" value="inc,install,module,php,profile,test,theme,yml"/>
+
+  <!--Blacklist of coding standard rules that are not yet fixed. -->
+  <rule ref="Drupal">
+    <exclude name="Drupal.Commenting.DocComment.MissingShort"/>
+    <exclude name="Drupal.Commenting.FunctionComment.IncorrectTypeHint"/>
+    <exclude name="Drupal.Commenting.FunctionComment.MissingReturnComment"/>
+    <exclude name="Drupal.NamingConventions.ValidVariableName.LowerCamelName"/>
+  </rule>
+</ruleset>
diff --git a/core/modules/jsonapi/src/Access/CustomParameterNames.php b/core/modules/jsonapi/src/Access/CustomParameterNames.php
new file mode 100644
index 0000000..a8ec382
--- /dev/null
+++ b/core/modules/jsonapi/src/Access/CustomParameterNames.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\jsonapi\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Validates custom parameter names.
+ *
+ * @internal
+ */
+class CustomParameterNames implements AccessInterface {
+
+  /**
+   * Validates the JSONAPI 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 the JSONAPI parameters.
+   *
+   * @see http://jsonapi.org/format/#document-member-names-reserved-characters
+   *
+   * @param string[] $json_api_params
+   *   The JSONAPI parameters.
+   *
+   * @return bool
+   */
+  protected function validate(array $json_api_params) {
+    $valid = TRUE;
+
+    foreach (array_keys($json_api_params) as $name) {
+      if (strpbrk($name, "+,.[]!”#$%&’()*/:;<=>?@\\^`{}~|\x0\x1\x2\x3\x4\x5\x6\x7\x8\x9\xA\xB\xC\xD\xE\xF\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F")) {
+        $valid = FALSE;
+        break;
+      }
+
+      if (strpbrk($name[0], '-_ ') || strpbrk($name[strlen($name) - 1], '-_ ')) {
+        $valid = FALSE;
+        break;
+      }
+    }
+
+    return $valid;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Context/CurrentContext.php b/core/modules/jsonapi/src/Context/CurrentContext.php
new file mode 100644
index 0000000..bcd42a1
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/CurrentContext.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\jsonapi\Context;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+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\ResourceTypeRepository
+   */
+  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\ResourceTypeRepository $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(ResourceTypeRepository $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) {
+    return $this
+      ->requestStack
+      ->getCurrentRequest()
+      ->attributes
+      ->get("_json_api_params[$parameter_key]", NULL, TRUE);
+  }
+
+  /**
+   * 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 [];
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php
new file mode 100644
index 0000000..9eab90d
--- /dev/null
+++ b/core/modules/jsonapi/src/Context/FieldResolver.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\jsonapi\Context;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+
+/**
+ * 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 field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * Creates a FieldResolver instance.
+   *
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The JSON API CurrentContext service.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The field manager.
+   */
+  public function __construct(CurrentContext $current_context, EntityFieldManagerInterface $field_manager) {
+    $this->currentContext = $current_context;
+    $this->fieldManager = $field_manager;
+  }
+
+  /**
+   * Maps a Drupal field name to a public field name.
+   *
+   * Example:
+   *   'field_author.entity.field_first_name' -> 'author.firstName'.
+   *
+   * @param string $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) {
+    // Yet to be implemented.
+    return $internal_field_name;
+  }
+
+  /**
+   * Maps a public field name to a Drupal field name.
+   *
+   * Example:
+   *   'author.firstName' -> 'field_author.entity.field_first_name'.
+   *
+   * @param string $field_name
+   *   The public field name to map to a Drupal field name.
+   *
+   * @return string
+   *   The mapped field name.
+   */
+  public function resolveInternal($external_field_name) {
+    if (empty($external_field_name)) {
+      throw new SerializableHttpException(400, 'No field name was provided for the filter.');
+    }
+    // Right now we are exposing all the fields with the name they have in
+    // the Drupal backend. But this may change in the future.
+    if (strpos($external_field_name, '.') === FALSE) {
+      return $external_field_name;
+    }
+    // Turns 'uid.field_category.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);
+    $entity_type_id = $this->currentContext->getResourceType()->getEntityTypeId();
+    $reference_breadcrumbs = [];
+    while ($field_name = array_shift($parts)) {
+      if (!$definitions = $this->fieldManager->getFieldStorageDefinitions($entity_type_id)) {
+        throw new SerializableHttpException(400, sprintf('Invalid nested filtering. There is no entity type "%s".', $entity_type_id));
+      }
+      if (empty($definitions[$field_name])) {
+        throw new SerializableHttpException(400, sprintf('Invalid nested filtering. Invalid entity reference "%s".', $field_name));
+      }
+      array_push($reference_breadcrumbs, $field_name);
+      // Update the entity type with the referenced type.
+      $entity_type_id = $definitions[$field_name]->getSetting('target_type');
+      // $field_name may not be a reference field. In that case we should treat
+      //the rest of the parts as complex fields.
+      if (empty($entity_type_id)) {
+        // This is the path from the initial entity type to the entity type that
+        // contains $field_name. This path is a set of entity references.
+        $entity_path = implode('.entity.', $reference_breadcrumbs);
+        // This is the path from the final entity type to the selected field
+        //column.
+        $field_path = implode('.', $parts);
+
+        return implode('.', array_filter([$entity_path, $field_path]));
+      }
+    }
+
+    return implode('.entity.', $reference_breadcrumbs);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php
new file mode 100644
index 0000000..907171b
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -0,0 +1,736 @@
+<?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\FieldTypePluginManagerInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\Query\QueryBuilder;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\Routing\Param\JsonApiParamBase;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * @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 query builder service.
+   *
+   * @var \Drupal\jsonapi\Query\QueryBuilder
+   */
+  protected $queryBuilder;
+
+  /**
+   * The current context service.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * The current context service.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * 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\jsonapi\Query\QueryBuilder $query_builder
+   *   The query builder.
+   * @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.
+   */
+  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, QueryBuilder $query_builder, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager) {
+    $this->resourceType = $resource_type;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->queryBuilder = $query_builder;
+    $this->fieldManager = $field_manager;
+    $this->currentContext = $current_context;
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * 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 SerializableHttpException(403, 'The current user is not allowed to GET the selected resource.');
+    }
+    $response = $this->buildWrappedResponse($entity, $response_code);
+    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\SerializableHttpException
+   *   If validation errors are found.
+   */
+  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).
+      $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.
+   */
+  public function createIndividual(EntityInterface $entity, Request $request) {
+    $entity_access = $entity->access('create', NULL, TRUE);
+
+    if (!$entity_access->isAllowed()) {
+      throw new SerializableHttpException(403, 'The current user is not allowed to POST the selected resource.');
+    }
+    $this->validate($entity);
+    $entity->save();
+    return $this->getIndividual($entity, $request, 201);
+  }
+
+  /**
+   * 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.
+   */
+  public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) {
+    $entity_access = $entity->access('update', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new SerializableHttpException(403, '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 SerializableHttpException(400, 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->getIndividual($entity, $request);
+  }
+
+  /**
+   * 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.
+   */
+  public function deleteIndividual(EntityInterface $entity, Request $request) {
+    $entity_access = $entity->access('delete', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new SerializableHttpException(403, '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.
+   */
+  public function getCollection(Request $request) {
+    // Instantiate the query for the filtering.
+    $entity_type_id = $this->resourceType->getEntityTypeId();
+
+    $params = $request->attributes->get('_route_params[_json_api_params]', NULL, TRUE);
+    $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);
+    $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.
+   */
+  public function getRelated(EntityInterface $entity, $related_field, Request $request) {
+    /* @var $field_list \Drupal\Core\Field\FieldItemListInterface */
+    if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) {
+      throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field));
+    }
+    $is_multiple = $field_list
+      ->getDataDefinition()
+      ->getFieldStorageDefinition()
+      ->isMultiple();
+    if (!$is_multiple) {
+      return $this->getIndividual($field_list->entity, $request);
+    }
+    $collection_data = [];
+    $cacheable_metadata = new CacheableMetadata();
+    // Add the cacheable metadata from the host entity.
+    $cacheable_metadata->addCacheableDependency($entity);
+    foreach ($field_list as $field_item) {
+      /* @var \Drupal\Core\Entity\EntityInterface $entity_item */
+      $entity_item = $field_item->entity;
+      $collection_data[$entity_item->id()] = static::getEntityAndAccess($entity_item);
+      $cacheable_metadata->addCacheableDependency($entity_item);
+    }
+    $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) {
+    if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) {
+      throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field));
+    }
+    $response = $this->buildWrappedResponse($field_list, $response_code);
+    return $response;
+  }
+
+  /**
+   * 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.
+   */
+  public function createRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) {
+    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, $related_field);
+    // 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 SerializableHttpException(409, 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()) {
+      throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_list->getName()));
+    }
+    // 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) {
+    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, $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 SerializableHttpException(400, 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 SerializableHttpException(403, 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 SerializableHttpException(400, 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, $related_field);
+
+    $field_name = $parsed_field_list->getName();
+    $field_access = $parsed_field_list->access('edit', NULL, TRUE);
+    if (!$field_access->isAllowed()) {
+      throw new SerializableHttpException(403, 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 SerializableHttpException(409, 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 \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   *   The parameters for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   */
+  protected function getCollectionQuery($entity_type_id, $params) {
+    $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+    $query = $this->queryBuilder->newQuery($entity_type, $params);
+
+    // 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 \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   *   The parameters for the query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A new query.
+   */
+  protected function getCollectionCountQuery($entity_type_id, $params) {
+    // Override the pagination parameter to get all the available results.
+    $params[OffsetPage::KEY_NAME] = new JsonApiParamBase([]);
+    return $this->getCollectionQuery($entity_type_id, $params);
+  }
+
+  /**
+   * 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\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 $related_field
+   *   The name of the field to check.
+   */
+  protected function relationshipAccess(EntityInterface $entity, $related_field) {
+    /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */
+    $entity_access = $entity->access('update', NULL, TRUE);
+    if (!$entity_access->isAllowed()) {
+      throw new SerializableHttpException(403, 'The current user is not allowed to update the selected resource.');
+    }
+    if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) {
+      throw new SerializableHttpException(404, 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 EntityInterface $origin
+   *   The entity that contains the field values.
+   * @param 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 {
+        $destination_field_list = $destination->get($field_name);
+      }
+      catch (\Exception $e) {
+        throw new SerializableHttpException(400, 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 ($destination_field_list->getValue() != $origin_field_list->getValue()) {
+        $field_access = $destination_field_list->access('edit', NULL, TRUE);
+        if (!$field_access->isAllowed()) {
+          throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $destination_field_list->getName()));
+        }
+        $destination->{$field_name} = $origin->get($field_name);
+      }
+    }
+    elseif ($origin instanceof ConfigEntityInterface && $destination instanceof ConfigEntityInterface) {
+      // Second scenario: both are config entities.
+      $destination->set($field_name, $origin->get($field_name));
+    }
+    else {
+      throw new SerializableHttpException(400, 'The serialized entity and the destination entity are of different types.');
+    }
+  }
+
+  /**
+   * Checks if is a relationship field.
+   *
+   * @param object $entity_field
+   *   Entity definition.
+   * @return bool
+   *   Returns TRUE, if entity field is EntityReferenceItem.
+   */
+  protected function isRelationshipField($entity_field) {
+    $class = $this->pluginManager->getPluginClass($entity_field->getDataDefinition()->getType());
+    return ($class == EntityReferenceItem::class || is_subclass_of($class, EntityReferenceItem::class));
+  }
+
+  /**
+   * 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, $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) {
+    $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 SerializableHttpException(403, sprintf(
+        'Access checks failed for entity %s:%s.',
+        $entity->getEntityTypeId(),
+        $entity->id()
+      ));
+    }
+
+    return $output;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Controller/RequestHandler.php b/core/modules/jsonapi/src/Controller/RequestHandler.php
new file mode 100644
index 0000000..c318b5a
--- /dev/null
+++ b/core/modules/jsonapi/src/Controller/RequestHandler.php
@@ -0,0 +1,289 @@
+<?php
+
+namespace Drupal\jsonapi\Controller;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Error\ErrorHandler;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\ResourceResponse;
+use Symfony\Component\DependencyInjection\ContainerAwareInterface;
+use Symfony\Component\DependencyInjection\ContainerAwareTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+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 implements ContainerAwareInterface, ContainerInjectionInterface {
+
+  use ContainerAwareTrait;
+
+  protected static $requiredCacheContexts = ['user.permissions'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static();
+  }
+
+  /**
+   * 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.
+    /* @var \Symfony\Component\Serializer\SerializerInterface $serializer */
+    $serializer = $this->container->get('serializer');
+    /* @var \Drupal\jsonapi\Context\CurrentContext $current_context */
+    $current_context = $this->container->get('jsonapi.current_context');
+    $unserialized = $this->deserializeBody($request, $serializer, $route->getOption('serialization_class'), $current_context);
+    $format = $request->getRequestFormat();
+    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 = array();
+
+    // 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.
+    // All REST routes are restricted to exactly one format, so instead of
+    // parsing it out of the Accept headers again, we can simply retrieve the
+    // format requirement. If there is no format associated, just pick JSON.
+    $action = $this->action($route_match, $method);
+    $resource = $this->resourceFactory($route, $current_context);
+
+    // Only add the unserialized data if there is something there.
+    $extra_parameters = $unserialized ? [$unserialized, $request] : [$request];
+
+    /** @var \Drupal\jsonapi\Error\ErrorHandler $error_handler */
+    $error_handler = $this->container->get('jsonapi.error_handler');
+    $error_handler->register();
+    // 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 */
+    $response = $this->container->get('renderer')
+      ->executeInRenderContext($context, function () use ($resource, $action, $parameters, $extra_parameters) {
+        return call_user_func_array([$resource, $action], array_merge($parameters, $extra_parameters));
+      });
+    if (!$context->isEmpty()) {
+      $response->addCacheableDependency($context->pop());
+    }
+    $error_handler->restore();
+
+    return $this->renderJsonApiResponse($request, $response, $serializer, $format, $error_handler);
+  }
+
+  /**
+   * Renders a resource response.
+   *
+   * 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\Core\Cache\CacheableResponseInterface $response
+   *   The response from the REST resource.
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer to use.
+   * @param string $format
+   *   The response format.
+   * @param \Drupal\jsonapi\Error\ErrorHandler $error_handler
+   *   The error handler service.
+   *
+   * @return \Drupal\Core\Cache\CacheableResponseInterface
+   *   The altered response.
+   */
+  protected function renderJsonApiResponse(Request $request, ResourceResponse $response, SerializerInterface $serializer, $format, ErrorHandler $error_handler) {
+    $data = $response->getResponseData();
+    $context = new RenderContext();
+
+    $cacheable_metadata = $response->getCacheableMetadata();
+    // Make sure to include the default cacheable metadata, since it won't be
+    // added if you don't user render arrays and the HtmlRenderer. We are not
+    // using the container variable '%renderer.config%' because is too tied to
+    // HTML generation.
+    $cacheable_metadata->addCacheContexts(static::$requiredCacheContexts);
+
+    // Make sure that any PHP error is surfaced as a serializable exception.
+    $error_handler->register();
+    $output = $this->container->get('renderer')
+      ->executeInRenderContext($context, function () use (
+        $serializer,
+        $data,
+        $format,
+        $request,
+        $cacheable_metadata
+      ) {
+        // 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' => $cacheable_metadata]);
+      });
+    $error_handler->restore();
+    $response->setContent($output);
+    if (!$context->isEmpty()) {
+      $response->addCacheableDependency($context->pop());
+    }
+
+    $response->headers->set('Content-Type', $request->getMimeType($format));
+    // Add rest settings config's cache tags.
+    $response->addCacheableDependency($this->container->get('config.factory')
+      ->get('jsonapi.resource_info'));
+
+    return $response;
+  }
+
+  /**
+   * Deserializes the sent data.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request.
+   * @param \Symfony\Component\Serializer\SerializerInterface $serializer
+   *   The serializer for the deserialization of the input data.
+   * @param string $serialization_class
+   *   The class the input data needs to deserialize into.
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The current context
+   *
+   * @return mixed
+   *   The deserialized data or a Response object in case of error.
+   */
+  public function deserializeBody(Request $request, SerializerInterface $serializer, $serialization_class, CurrentContext $current_context) {
+    $received = $request->getContent();
+    if (empty($received)) {
+      return NULL;
+    }
+    $format = $request->getContentType();
+    try {
+      return $serializer->deserialize($received, $serialization_class, $format, [
+        'related' => $request->get('related'),
+        'target_entity' => $request->get($current_context->getResourceType()->getEntityTypeId()),
+        'resource_type' => $current_context->getResourceType(),
+      ]);
+    }
+    catch (UnexpectedValueException $e) {
+      throw new SerializableHttpException(
+        422,
+        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 '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.
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   The current context.
+   *
+   * @return \Drupal\jsonapi\Controller\EntityResource
+   *   The instantiated resource.
+   */
+  protected function resourceFactory(Route $route, CurrentContext $current_context) {
+    /** @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository $resource_type_repository */
+    $resource_type_repository = $this->container->get('jsonapi.resource_type.repository');
+    /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
+    $entity_type_manager = $this->container->get('entity_type.manager');
+    /* @var \Drupal\jsonapi\Query\QueryBuilder $query_builder */
+    $query_builder = $this->container->get('jsonapi.query_builder');
+    /* @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */
+    $field_manager = $this->container->get('entity_field.manager');
+    /* @var \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager */
+    $plugin_manager = $this->container->get('plugin.manager.field.field_type');
+    $resource = new EntityResource(
+      $resource_type_repository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')),
+      $entity_type_manager,
+      $query_builder,
+      $field_manager,
+      $current_context,
+      $plugin_manager
+    );
+    return $resource;
+  }
+
+}
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/Error/ErrorHandler.php b/core/modules/jsonapi/src/Error/ErrorHandler.php
new file mode 100644
index 0000000..a51f646
--- /dev/null
+++ b/core/modules/jsonapi/src/Error/ErrorHandler.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\jsonapi\Error;
+
+/**
+ * @see http://jsonapi.org/format/#errors
+ *
+ * @see \Drupal\jsonapi\Controller\RequestHandler::renderJsonApiResponse
+ * @internal
+ */
+class ErrorHandler {
+
+  /**
+   * Register the handler.
+   */
+  public function register() {
+    set_error_handler(get_called_class() . '::handle');
+  }
+
+  /**
+   * Go back to normal and restore the previous error handler.
+   */
+  public function restore() {
+    restore_error_handler();
+  }
+
+  /**
+   * Handle the PHP error with custom business logic.
+   *
+   * @param $error_level
+   *   The level of the error raised.
+   * @param $message
+   *   The error message.
+   * @param $filename
+   *   The filename that the error was raised in.
+   * @param $line
+   *   The line number the error was raised at.
+   * @param $context
+   *   An array that points to the active symbol table at the point the error
+   *   occurred.
+   */
+  public static function handle($error_level, $message, $filename, $line, $context) {
+    $message = 'Unexpected PHP error: ' . $message;
+    _drupal_error_handler($error_level, $message, $filename, $line, $context);
+    $types = drupal_error_levels();
+    list($severity_msg, $severity_level) = $types[$error_level];
+    // Only halt execution if the error is more severe than a warning.
+    if ($severity_level < 4) {
+      throw new SerializableHttpException(500, sprintf('[%s] %s', $severity_msg, $message), NULL, [], $error_level);
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
new file mode 100644
index 0000000..fb21a3e
--- /dev/null
+++ b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\jsonapi\EventSubscriber;
+
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber as SerializationDefaultExceptionSubscriber;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * @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();
+    $format = $event->getRequest()->getRequestFormat();
+    if (!$this->serializer->supportsEncoding($format)) {
+      return;
+    }
+    if (!$exception instanceof HttpException) {
+      $exception = new SerializableHttpException(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();
+    $format = $event->getRequest()->getRequestFormat();
+    if (!$this->serializer->supportsNormalization($exception, $format)) {
+      return;
+    }
+    $encoded_content = $this->serializer->serialize($exception, $format, ['data_wrapper' => 'errors']);
+    $response = new Response($encoded_content, $status);
+    $event->setResponse($response);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Exception/SerializableHttpException.php b/core/modules/jsonapi/src/Exception/SerializableHttpException.php
new file mode 100644
index 0000000..3aaa117
--- /dev/null
+++ b/core/modules/jsonapi/src/Exception/SerializableHttpException.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Drupal\jsonapi\Exception;
+
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * @internal
+ */
+class SerializableHttpException extends HttpException {
+
+  use DependencySerializationTrait;
+
+}
diff --git a/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php b/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php
new file mode 100644
index 0000000..7730f5f
--- /dev/null
+++ b/core/modules/jsonapi/src/Exception/UnprocessableHttpEntityException.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\jsonapi\Exception;
+
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+
+/**
+ * @internal
+ */
+class UnprocessableHttpEntityException extends SerializableHttpException {
+
+  /**
+   * The constraint violations associated with this exception.
+   *
+   * @var \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   */
+  protected $violations;
+
+  /**
+   * UnprocessableHttpEntityException constructor.
+   *
+   * @param array $violations
+   * @param \Exception|null $previous
+   * @param array $headers
+   * @param int $code
+   */
+  public function __construct(\Exception $previous = NULL, array $headers = array(), $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
+   */
+  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..c797871
--- /dev/null
+++ b/core/modules/jsonapi/src/Field/FileDownloadUrl.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\jsonapi\Field;
+
+use Drupal\Core\Field\FieldItemList;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * @internal
+ */
+class FileDownloadUrl extends FieldItemList {
+
+  /**
+   * 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) {
+    return file_url_transform_relative(file_create_url($uri));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getValue($include_computed = FALSE) {
+    $this->initList();
+
+    return parent::getValue($include_computed);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
+    return $this->getEntity()
+      ->get('uri')
+      ->access($operation, $account, $return_as_object);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isEmpty() {
+    return $this->getEntity()->get('uri')->isEmpty();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIterator() {
+    $this->initList();
+
+    return parent::getIterator();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($index) {
+    $this->initList();
+
+    return parent::get($index);
+  }
+
+  /**
+   * Initialize the internal field list with the modified items.
+   */
+  protected function initList() {
+    if ($this->list) {
+      return;
+    }
+    $url_list = [];
+    foreach ($this->getEntity()->get('uri') as $uri_item) {
+      $url_item = clone $uri_item;
+      $uri = $uri_item->value;
+      $url_item->setValue($this->fileCreateRootRelativeUrl($uri));
+      $url_list[] = $url_item;
+    }
+    $this->list = $url_list;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/JsonapiServiceProvider.php b/core/modules/jsonapi/src/JsonapiServiceProvider.php
new file mode 100644
index 0000000..b5f9ea9
--- /dev/null
+++ b/core/modules/jsonapi/src/JsonapiServiceProvider.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+
+/**
+ * Adds api_json as known format.
+ *
+ * @internal
+ */
+class JsonapiServiceProvider implements ServiceModifierInterface {
+
+  /**
+   * {@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'],
+        ]);
+    }
+  }
+
+}
diff --git a/core/modules/jsonapi/src/LinkManager/LinkManager.php b/core/modules/jsonapi/src/LinkManager/LinkManager.php
new file mode 100644
index 0000000..b3f6d03
--- /dev/null
+++ b/core/modules/jsonapi/src/LinkManager/LinkManager.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace Drupal\jsonapi\LinkManager;
+
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Matcher\RequestMatcherInterface;
+
+/**
+ * @internal
+ */
+class LinkManager {
+
+  /**
+   * @var \Symfony\Component\Routing\Matcher\RequestMatcherInterface
+   */
+  protected $router;
+
+  /**
+   * @var \Drupal\Core\Render\MetadataBubblingUrlGenerator
+   */
+  protected $urlGenerator;
+
+  /**
+   * Instantiates a LinkManager object.
+   *
+   * @param \Symfony\Component\Routing\Matcher\RequestMatcherInterface $router
+   *   The router.
+   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
+   *   The Url generator.
+   */
+  public function __construct(RequestMatcherInterface $router, UrlGeneratorInterface $url_generator) {
+    $this->router = $router;
+    $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,
+      '_format' => 'api_json',
+    ];
+    $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) {
+    $query = $query ?: (array) $request->query->getIterator();
+    $result = $this->router->matchRequest($request);
+    $route_name = $result[RouteObjectInterface::ROUTE_NAME];
+    /* @var \Symfony\Component\HttpFoundation\ParameterBag $raw_variables */
+    $raw_variables = $result['_raw_variables'];
+    $route_parameters = $raw_variables->all();
+    $options = [
+      'absolute' => TRUE,
+      'query' => $query,
+    ];
+    return $this->urlGenerator->generateFromRoute($route_name, $route_parameters, $options);
+  }
+
+  /**
+   * 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 \Drupal\jsonapi\Exception\SerializableHttpException
+   *   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 = []) {
+    $params = $request->get('_json_api_params');
+    if ($page_param = $params[OffsetPage::KEY_NAME]) {
+      /* @var \Drupal\jsonapi\Routing\Param\OffsetPage $page_param */
+      $offset = $page_param->getOffset();
+      $size = $page_param->getSize();
+    }
+    else {
+      // Apply the defaults.
+      $offset = 0;
+      $size = OffsetPage::$maxSize;
+    }
+    if ($size <= 0) {
+      throw new SerializableHttpException(400, 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));
+    }
+    // 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.
+   *
+   * @return array
+   *   The pagination query param array.
+   */
+  protected function getPagerQueries($link_id, $offset, $size, $query = []) {
+    $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 'prev':
+        $extra_query = [
+          'page' => [
+            'offset' => max($offset - $size, 0),
+            'limit' => $size,
+          ],
+        ];
+        break;
+    }
+    return array_merge($query, $extra_query);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
new file mode 100644
index 0000000..e942072
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+
+/**
+ * Converts the Drupal config entity object to a JSON API array structure.
+ */
+class ConfigEntityNormalizer extends EntityNormalizer {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFields($entity, $bundle) {
+    /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
+    return $entity->toArray();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function serializeField($field, $context, $format) {
+    $output = $this->serializer->normalize($field, $format, $context);
+    if (is_array($output)) {
+      $output = new FieldNormalizerValue(
+        [new FieldItemNormalizerValue($output)],
+        1
+      );
+      $output->setPropertyType('attributes');
+      return $output;
+    }
+    $field instanceof Relationship ?
+      $output->setPropertyType('relationships') :
+      $output->setPropertyType('attributes');
+    return $output;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php
new file mode 100644
index 0000000..6f8fcf0
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+/**
+ * Converts the Drupal content entity object to a JSON API array structure.
+ */
+class ContentEntityNormalizer extends EntityNormalizer {}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php
new file mode 100644
index 0000000..7958248
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityNormalizer.php
@@ -0,0 +1,215 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessibleInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal entity object to a JSON API array structure.
+ */
+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 = array('api_json');
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs an ContentEntityNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepository $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, ResourceTypeRepository $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 = array()) {
+    // 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);
+    }
+    /* @var Value\FieldNormalizerValueInterface[] $normalizer_values */
+    $normalizer_values = [];
+    foreach ($this->getFields($entity, $bundle) as $field_name => $field) {
+      if (!in_array($field_name, $field_names)) {
+        continue;
+      }
+      $normalizer_values[$field_name] = $this->serializeField($field, $context, $format);
+    }
+
+    $link_context = ['link_manager' => $this->linkManager];
+    $output = new EntityNormalizerValue($normalizer_values, $context, $entity, $link_context);
+    // Add the entity level cacheability metadata.
+    $output->addCacheableDependency($entity);
+    $output->addCacheableDependency($output);
+    // Add the field level cacheability metadata.
+    array_walk($normalizer_values, function ($normalizer_value) {
+      if ($normalizer_value instanceof RefinableCacheableDependencyInterface) {
+        $normalizer_value->addCacheableDependency($normalizer_value);
+      }
+    });
+    return $output;
+  }
+
+  /**
+   * 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 function isRelationship($field) {
+    return $field instanceof EntityReferenceFieldItemList || $field instanceof Relationship;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) {
+      throw new SerializableHttpException(412, '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($data);
+  }
+
+  /**
+   * Gets the field names for the given entity.
+   *
+   * @param mixed $entity
+   *   The entity.
+   *
+   * @return string[]
+   *   The field names.
+   */
+  protected function getFieldNames($entity, $bundle) {
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    return array_keys($this->getFields($entity, $bundle));
+  }
+
+  /**
+   * Gets the field names for the given entity.
+   *
+   * @param mixed $entity
+   *   The entity.
+   * @param string $bundle
+   *   The bundle id.
+   *
+   * @return array
+   *   The fields.
+   */
+  protected function getFields($entity, $bundle) {
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    return $entity->getFields();
+  }
+
+  /**
+   * 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, $context, $format) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface|\Drupal\jsonapi\Normalizer\Relationship $field */
+    // Continue if the current user does not have access to view this field.
+    $access = $field->access('view', $context['account'], TRUE);
+    if ($field instanceof AccessibleInterface && !$access->isAllowed()) {
+      return (new NullFieldNormalizerValue())->addCacheableDependency($access);
+    }
+    /** @var \Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue $output */
+    $output = $this->serializer->normalize($field, $format, $context);
+    $is_relationship = $this->isRelationship($field);
+    $property_type = $is_relationship ? 'relationships' : 'attributes';
+    $output->setPropertyType($property_type);
+
+    if ($output instanceof RefinableCacheableDependencyInterface) {
+      // Add the cache dependency to the field level object because we want to
+      // allow the field normalizers to add extra cacheability metadata.
+      $output->addCacheableDependency($access);
+    }
+
+    return $output;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
new file mode 100644
index 0000000..bdddd71
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityRepositoryInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Normalizer class specific for entity reference field objects.
+ */
+class EntityReferenceFieldNormalizer extends FieldNormalizer implements DenormalizerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
+
+  /**
+   * The link manager.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The field plugin manager.
+   *
+   * @param \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\ResourceTypeRepository $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, ResourceTypeRepository $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 = array()) {
+    /* @var $field \Drupal\Core\Field\FieldItemListInterface */
+    // Build the relationship object based on the Entity Reference and normalize
+    // that object instead.
+    $main_property = $field->getItemDefinition()->getMainPropertyName();
+    $definition = $field->getFieldDefinition();
+    $cardinality = $definition
+      ->getFieldStorageDefinition()
+      ->getCardinality();
+    $entity_collection = new EntityCollection(array_map(function ($item) {
+      return $item->get('entity')->getValue();
+    }, (array) $field->getIterator()));
+    $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $cardinality, $entity_collection, $field->getEntity(), $main_property);
+    return $this->serializer->normalize($relationship, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    // 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 SerializableHttpException(400, '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 SerializableHttpException(400, 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;
+
+      return [$property_key => $value['id']];
+    }, $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($data, $is_multiple) {
+    if ($is_multiple) {
+      if (!is_array($data['data'])) {
+        throw new SerializableHttpException(400, '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 SerializableHttpException(400, '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 SerializableHttpException(400, 'Invalid body payload for the relationship.');
+      }
+      $data['data'] = [$data['data']];
+    }
+    return $data;
+  }
+
+  /**
+   * 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..477aa57
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Converts the Drupal field item object to a JSON API array structure.
+ */
+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 = array('api_json');
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field_item, $format = NULL, array $context = array()) {
+    $values = $field_item->toArray();
+    if (isset($context['langcode'])) {
+      $values['lang'] = $context['langcode'];
+    }
+    return new FieldItemNormalizerValue($values);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    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..5d4ab5b
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Converts the Drupal field structure to a JSON API array structure.
+ */
+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 = array('api_json');
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = array()) {
+    /* @var \Drupal\Core\Field\FieldItemListInterface $field */
+    return $this->normalizeFieldItems($field, $format, $context);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    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\FieldNormalizerValue
+   *   The array of normalized field items.
+   */
+  protected function normalizeFieldItems(FieldItemListInterface $field, $format, $context) {
+    $normalizer_items = array();
+    if (!$field->isEmpty()) {
+      foreach ($field as $field_item) {
+        $normalizer_items[] = $this->serializer->normalize($field_item, $format, $context);
+      }
+    }
+    $cardinality = $field->getFieldDefinition()
+      ->getFieldStorageDefinition()
+      ->getCardinality();
+    return new FieldNormalizerValue($normalizer_items, $cardinality);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
new file mode 100644
index 0000000..529f862
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue;
+use Drupal\serialization\Normalizer\NormalizerBase as SerializationNormalizerBase;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Normalizes an HttpException object for JSON output which complies with the
+ * JSON API specification.
+ *
+ * @see http://jsonapi.org/format/#error-objects
+ */
+class HttpExceptionNormalizer extends SerializationNormalizerBase {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = HttpException::class;
+
+  /**
+   * The current user making the request.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected $currentUser;
+
+  /**
+   * HttpExceptionNormalizer constructor.
+   *
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   */
+  public function __construct(AccountProxyInterface $current_user) {
+    $this->currentUser = $current_user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    $errors = $this->buildErrorObjects($object);
+
+    $errors = array_map(function($error) {
+      return new FieldItemNormalizerValue([$error]);
+    }, $errors);
+
+    return new HttpExceptionNormalizerValue(
+      $errors,
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+  }
+
+  /**
+   * 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(),
+      'links' => [
+        'info' => $this->getInfoUrl($status_code),
+      ],
+      '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
+   *   URL pointing to the specific RFC-2616 section.
+   */
+  protected 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 = array(
+      '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]) ? $url : $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..d3aaab0
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
+use Drupal\jsonapi\Resource\EntityCollection;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * @see \Drupal\jsonapi\Resource\JsonApiDocumentTopLevel
+ */
+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;
+
+  /**
+   * Constructs an ContentEntityNormalizer 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.
+   */
+  public function __construct(LinkManager $link_manager, CurrentContext $current_context, EntityTypeManagerInterface $entity_type_manager) {
+    $this->linkManager = $link_manager;
+    $this->currentContext = $current_context;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    $context += [
+      'on_relationship' => $this->currentContext->isOnRelationship(),
+    ];
+    $normalized = [];
+    if (!empty($data['data']['attributes'])) {
+      $normalized = $data['data']['attributes'];
+    }
+    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) {
+        $id_list = array_column($relationship['data'], 'id');
+        list($entity_type_id,) = explode('--', $relationship['data'][0]['type']);
+        $entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
+        // In order to maintain the order ($delta) of the relationships, we need
+        // to load the entities and explore the uuid value.
+        $related_entities = array_values($entity_storage->loadByProperties(['uuid' => $id_list]));
+        $map = [];
+        foreach ($related_entities as $related_entity) {
+          $map[$related_entity->uuid()] = $related_entity->id();
+        }
+        $canonical_ids = array_map(function ($input_value) use ($map) {
+          return empty($map[$input_value]) ? NULL : $map[$input_value];
+        }, $id_list);
+
+        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 = array()) {
+    $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)) {
+      $normalized['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 = array()) {
+    $context += $this->expandContext($context['request']);
+    if ($data instanceof EntityReferenceFieldItemListInterface) {
+      $output = $this->serializer->normalize($data, $format, $context);
+      // The only normalizer value that computes nested includes automatically is the JsonApiDocumentTopLevelNormalizerValue
+      $output->setIncludes($output->getAllIncludes());
+      return $output;
+    }
+    else {
+      $is_collection = $data instanceof EntityCollection;
+      // 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;
+      $serializer = $this->serializer;
+      $normalizer_values = array_map(function ($entity) use ($format, $context, $serializer) {
+        return $serializer->normalize($entity, $format, $context);
+      }, $entities);
+    }
+
+    return new JsonApiDocumentTopLevelNormalizerValue($normalizer_values, $context, $is_collection, [
+      'link_manager' => $this->linkManager,
+      'has_next_page' => $context['has_next_page'],
+    ]);
+  }
+
+  /**
+   * Expand the context information based on the current request context.
+   *
+   * @param Request $request
+   *   The request to get the URL params from to expand the context.
+   *
+   * @return array
+   *   The expanded context.
+   */
+  protected function expandContext(Request $request) {
+    $context = array(
+      'account' => NULL,
+      'sparse_fieldset' => NULL,
+      'resource_type' => NULL,
+      'include' => array_filter(explode(',', $request->query->get('include'))),
+    );
+    if (isset($this->currentContext)) {
+      $context['resource_type'] = $this->currentContext->getResourceType();
+    }
+    if ($request->query->get('fields')) {
+      $context['sparse_fieldset'] = array_map(function ($item) {
+        return explode(',', $item);
+      }, $request->query->get('fields'));
+    }
+
+    return $context;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/NormalizerBase.php b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
new file mode 100644
index 0000000..5447661
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\serialization\Normalizer\NormalizerBase as SerializationNormalizerBase;
+
+/**
+ * Base normalizer used in all JSON API normalizers.
+ */
+abstract class NormalizerBase extends SerializationNormalizerBase {
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = array('api_json');
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * {@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/Relationship.php b/core/modules/jsonapi/src/Normalizer/Relationship.php
new file mode 100644
index 0000000..2483b6d
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Relationship.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessibleInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Resource\EntityCollection;
+
+/**
+ * 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 {
+
+  /**
+   * 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\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The relationship items.
+   *
+   * @var \Drupal\jsonapi\Normalizer\RelationshipItem[]
+   */
+  protected $items;
+
+  /**
+   * Relationship constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepository $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param string $field_name
+   *   The name of the relationship.
+   * @param int $cardinality
+   *   The relationship cardinality.
+   * @param \Drupal\jsonapi\Resource\EntityCollection $entities
+   *   A collection of entities.
+   * @param \Drupal\Core\Entity\EntityInterface $host_entity
+   *   The host entity.
+   * @param string $target_key
+   *   The property name of the relationship id.
+   */
+  public function __construct(ResourceTypeRepository $resource_type_repository, $field_name, $cardinality = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, EntityCollection $entities, EntityInterface $host_entity, $target_key = 'target_id') {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->propertyName = $field_name;
+    $this->cardinality = $cardinality;
+    $this->hostEntity = $host_entity;
+    $this->items = [];
+    foreach ($entities as $entity) {
+      $this->items[] = new RelationshipItem(
+        $resource_type_repository,
+        $entity,
+        $this,
+        $target_key
+      );
+    }
+  }
+
+  /**
+   * Gets the cardinality.
+   *
+   * @return mixed
+   */
+  public function getCardinality() {
+    return $this->cardinality;
+  }
+
+  /**
+   * Gets the host entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   */
+  public function getHostEntity() {
+    return $this->hostEntity;
+  }
+
+  /**
+   * Sets the host entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $hostEntity
+   */
+  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
+   */
+  public function getPropertyName() {
+    return $this->propertyName;
+  }
+
+  /**
+   * Gets the items.
+   *
+   * @return \Drupal\jsonapi\Normalizer\RelationshipItem[]
+   */
+  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..b10201e
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+
+/**
+ * @internal
+ */
+class RelationshipItem {
+
+  /**
+   * The target key name.
+   *
+   * @param string
+   */
+  protected $targetKey = 'target_id';
+
+  /**
+   * The target entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface
+   */
+  protected $targetEntity;
+
+  /**
+   * The target JSON API resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $targetResourceType;
+
+  /**
+   * The parent relationship.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Relationship
+   */
+  protected $parent;
+
+  /**
+   * Relationship item constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepository $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.
+   */
+  public function __construct(ResourceTypeRepository $resource_type_repository, EntityInterface $target_entity, Relationship $parent, $target_key = 'target_id') {
+    $this->targetResourceType = $resource_type_repository->get(
+      $target_entity->getEntityTypeId(),
+      $target_entity->bundle()
+    );
+    $this->targetKey = $target_key;
+    $this->targetEntity = $target_entity;
+    $this->parent = $parent;
+  }
+
+  /**
+   * Gets the target entity.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   */
+  public function getTargetEntity() {
+    return $this->targetEntity;
+  }
+
+  /**
+   * Gets the targetResourceConfig.
+   *
+   * @return mixed
+   */
+  public function getTargetResourceType() {
+    return $this->targetResourceType;
+  }
+
+  /**
+   * Gets the relationship value.
+   *
+   * Defaults to the entity ID.
+   *
+   * @return string
+   */
+  public function getValue() {
+    return [$this->targetKey => $this->getTargetEntity()->uuid()];
+  }
+
+  /**
+   * Gets the relationship object that contains this relationship item.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Relationship
+   */
+  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..3c301c6
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\Controller\EntityResource;
+use Drupal\serialization\EntityResolver\UuidReferenceInterface;
+
+/**
+ * Converts the Drupal entity reference item object to a JSON API structure.
+ *
+ * @todo Remove the dependency on \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+ */
+class RelationshipItemNormalizer extends FieldItemNormalizer implements UuidReferenceInterface, RefinableCacheableDependencyInterface {
+
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = RelationshipItem::class;
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The JSON API document top level normalizer.
+   *
+   * @var \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+   */
+  protected $jsonapiDocumentToplevelNormalizer;
+
+  /**
+   * Instantiates a RelationshipItemNormalizer object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepository $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(ResourceTypeRepository $resource_type_repository, JsonApiDocumentTopLevelNormalizer $jsonapi_document_toplevel_normalizer) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->jsonapiDocumentToplevelNormalizer = $jsonapi_document_toplevel_normalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($relationship_item, $format = NULL, array $context = array()) {
+    /* @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'];
+    }
+    $normalizer_value = new RelationshipItemNormalizerValue(
+      $values,
+      $relationship_item->getTargetResourceType()
+    );
+
+    $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);
+      $normalizer_value->setInclude($included_normalizer_value);
+      $normalizer_value->addCacheableDependency($entity_and_access['access']);
+      $normalizer_value->addCacheableDependency($included_normalizer_value);
+      // Add the cacheable dependency of the included item directly to the
+      // response cacheable metadata. This is similar to the flatten include
+      // data structure, instead of a content graph.
+      if (!empty($context['cacheable_metadata'])) {
+        $context['cacheable_metadata']->addCacheableDependency($normalizer_value);
+      }
+    }
+    return $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($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;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getUuid($data) {
+    if (isset($data['uuid'])) {
+      return NULL;
+    }
+    $uuid = $data['uuid'];
+    // The value may be a nested array like $uuid[0]['value'].
+    if (is_array($uuid) && isset($uuid[0]['value'])) {
+      $uuid = $uuid[0]['value'];
+    }
+    return $uuid;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
new file mode 100644
index 0000000..dc5aadd
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * Normalizer class for relationship elements. A relationship can be anything
+ * that points to an entity in a JSON API resource.
+ */
+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 = array('api_json');
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * RelationshipNormalizer constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepository $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   */
+  public function __construct(ResourceTypeRepository $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 = array()) {
+    throw new UnexpectedValueException('Denormalization not implemented for JSON API');
+  }
+
+  /**
+   * Helper function to normalize field items.
+   *
+   * @param \Drupal\jsonapi\Normalizer\Relationship $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 = array()) {
+    /* @var \Drupal\jsonapi\Normalizer\Relationship $relationship */
+    $normalizer_items = array();
+    foreach ($relationship->getItems() as $relationship_item) {
+      $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'],
+    ];
+    return new RelationshipNormalizerValue($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($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/ScalarNormalizer.php b/core/modules/jsonapi/src/Normalizer/ScalarNormalizer.php
new file mode 100644
index 0000000..6e84f87
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/ScalarNormalizer.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Symfony\Component\Serializer\Exception\UnexpectedValueException;
+
+/**
+ * The normalizer used for scalar inputs.
+ */
+class ScalarNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL) {
+    return (!$data || is_scalar($data)) && in_array($format, $this->formats);
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = array()) {
+    $value = new FieldItemNormalizerValue(['value' => $object]);
+    return new FieldNormalizerValue([$value], 1);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = array()) {
+    throw new UnexpectedValueException('Denormalization not implemented for JSON API');
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php
new file mode 100644
index 0000000..2326d81
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Normalizes an UnprocessableHttpEntityException object for JSON output which
+ * complies 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
+ */
+class UnprocessableHttpEntityExceptionNormalizer extends HttpExceptionNormalizer {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = UnprocessableHttpEntityException::class;
+
+  /**
+   * UnprocessableHttpEntityException constructor.
+   *
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   */
+  public function __construct(AccountProxyInterface $current_user) {
+    parent::__construct($current_user);
+  }
+
+  /**
+   * {@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() . ': '
+          . $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/EntityNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php
new file mode 100644
index 0000000..b30a79b
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * @internal
+ */
+class EntityNormalizerValue implements ValueExtractorInterface, RefinableCacheableDependencyInterface  {
+
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * The values.
+   *
+   * @param array
+   */
+  protected $values;
+
+  /**
+   * The includes.
+   *
+   * @param array
+   */
+  protected $includes;
+
+  /**
+   * The resource path.
+   *
+   * @param array
+   */
+  protected $context;
+
+  /**
+   * The resource entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The link manager.
+   *
+   * @param \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->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);
+    array_walk($this->includes, function ($include) {
+      $this->addCacheableDependency($include);
+    });
+  }
+
+  /**
+   * {@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..0291982
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * @internal
+ */
+class FieldItemNormalizerValue implements ValueExtractorInterface {
+
+  /**
+   * Raw values.
+   *
+   * @param array
+   */
+  protected $raw;
+
+  /**
+   * Included entity objects.
+   *
+   * @param \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+   */
+  protected $include;
+
+  /**
+   * Instantiate a FieldItemNormalizerValue object.
+   *
+   * @param array $values
+   *   The normalized result.
+   */
+  public function __construct(array $values) {
+    $this->raw = $values;
+  }
+
+  /**
+   * {@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);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeIncludes() {
+    return $this->include->rasterizeValue();
+  }
+
+  /**
+   * Add an include.
+   *
+   * @param ValueExtractorInterface $include
+   *   The included entity.
+   */
+  public function setInclude(ValueExtractorInterface $include) {
+    $this->include = $include;
+  }
+
+  /**
+   * Gets the include.
+   *
+   * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+   *   The include.
+   */
+  public function getInclude() {
+    return $this->include;
+  }
+
+  /**
+   * 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..a3697d8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+
+/**
+ * @internal
+ */
+class FieldNormalizerValue implements FieldNormalizerValueInterface {
+
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * The values.
+   *
+   * @param array
+   */
+  protected $values;
+
+  /**
+   * The includes.
+   *
+   * @param array
+   */
+  protected $includes;
+
+  /**
+   * The field cardinality.
+   *
+   * @param integer
+   */
+  protected $cardinality;
+
+  /**
+   * The property type. Either: 'attributes' or `relationships'.
+   *
+   * @var string
+   */
+  protected $propertyType;
+
+  /**
+   * Instantiate a FieldNormalizerValue object.
+   *
+   * @param \Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue[] $values
+   *   The normalized result.
+   * @param int $cardinality
+   *   The cardinality of the field list.
+   */
+  public function __construct(array $values, $cardinality) {
+    $this->values = $values;
+    $this->includes = array_map(function ($value) {
+      return $value->getInclude();
+    }, $values);
+    $this->includes = array_filter($this->includes);
+    $this->cardinality = $cardinality;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (empty($this->values)) {
+      return NULL;
+    }
+    return $this->cardinality == 1 ?
+      $this->values[0]->rasterizeValue() :
+      array_map(function ($value) {
+        return $value->rasterizeValue();
+      }, $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 setPropertyType($property_type) {
+    $this->propertyType = $property_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setIncludes($includes) {
+    $this->includes = $includes;
+  }
+
+  /**
+   * {@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..72f102d
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+
+/**
+ * @internal
+ */
+interface FieldNormalizerValueInterface extends ValueExtractorInterface, RefinableCacheableDependencyInterface {
+
+  /**
+   * Gets the includes
+   *
+   * @return mixed
+   *   The includes.
+   */
+  public function getIncludes();
+
+  /**
+   * Gets the propertyType.
+   *
+   * @return mixed
+   *   The propertyType.
+   */
+  public function getPropertyType();
+
+  /**
+   * Sets the propertyType.
+   *
+   * @param mixed $property_type
+   *   The propertyType to set.
+   */
+  public function setPropertyType($property_type);
+
+  /**
+   * Sets the includes.
+   *
+   * This is used to manually set the nested includes when using the
+   * relationship as a document root in a
+   * /{resource}/{id}/relationships/{fieldName}.
+   *
+   * @param array $includes
+   *   The includes.
+   */
+  public function setIncludes($includes);
+
+  /**
+   * 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..9815603
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/HttpExceptionNormalizerValue.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * @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..628c9d8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+use Drupal\jsonapi\RequestCacheabilityDependency;
+
+/**
+ * @internal
+ */
+class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface, RefinableCacheableDependencyInterface  {
+
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * The values.
+   *
+   * @param array
+   */
+  protected $values;
+
+  /**
+   * The includes.
+   *
+   * @param array
+   */
+  protected $includes;
+
+  /**
+   * The resource path.
+   *
+   * @param array
+   */
+  protected $context;
+
+  /**
+   * Is collection?
+   *
+   * @param 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 bool $is_collection
+   *   TRUE if this is a serialization for a list.
+   * @param array $link_context
+   *   All the objects and variables needed to generate the links for this
+   *   relationship.
+   */
+  public function __construct(array $values, array $context, $is_collection = FALSE, array $link_context) {
+    $this->values = $values;
+    array_walk($values, [$this, 'addCacheableDependency']);
+    // Make sure that different sparse fieldsets are cached differently.
+    $this->addCacheableDependency(new RequestCacheabilityDependency());
+
+    $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' => []];
+
+    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 links.
+      if ($this->isCollection) {
+        $rasterized['links'] += $this->linkManager->getPagerLinks($request, $this->linkContext);
+      }
+    }
+    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();
+
+      $unique_key = $rasterized_include['data'] === FALSE ?
+        $rasterized_include['meta']['errors'][0]['detail'] :
+        $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..e11b9d1
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+
+/**
+ * @internal
+ */
+class NullFieldNormalizerValue implements FieldNormalizerValueInterface {
+
+  use RefinableCacheableDependencyTrait;
+
+  protected $propertyType;
+
+  public function getIncludes() {
+    return [];
+  }
+
+  public function getPropertyType() {
+    return $this->propertyType;
+  }
+
+  public function setPropertyType($property_type) {
+    $this->propertyType = $property_type;
+    return $this;
+  }
+
+  public function rasterizeValue() {
+    return NULL;
+  }
+
+  public function rasterizeIncludes() {
+    return [];
+  }
+
+  public function setIncludes($includes) {
+    // Do nothing.
+  }
+
+  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..8955eaf
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+
+/**
+ * @internal
+ */
+class RelationshipItemNormalizerValue extends FieldItemNormalizerValue implements RefinableCacheableDependencyInterface {
+
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * Resource path.
+   *
+   * @param string
+   */
+  protected $resource;
+
+  /**
+   * Instantiates a EntityReferenceItemNormalizerValue object.
+   *
+   * @param array $values
+   *   The values.
+   * @param string $resource
+   *   The resource type of the target entity.
+   */
+  public function __construct(array $values, $resource) {
+    parent::__construct($values);
+    $this->resource = $resource;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (!$value = parent::rasterizeValue()) {
+      return $value;
+    }
+    return [
+      'type' => $this->resource->getTypeName(),
+      'id' => $value,
+    ];
+  }
+
+  /**
+   * Sets the resource.
+   *
+   * @param string $resource
+   *   The resource to set.
+   */
+  public function setResource($resource) {
+    $this->resource = $resource;
+  }
+
+}
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..d4450d1
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * @internal
+ */
+class RelationshipNormalizerValue extends FieldNormalizerValue {
+
+  /**
+   * The link manager.
+   *
+   * @param \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 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(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($values, $cardinality);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rasterizeValue() {
+    if (!$value = parent::rasterizeValue()) {
+      // According to the JSON API specs empty relationships are either NULL or
+      // an empty array.
+      return $this->cardinality == 1 ? ['data' => NULL] : ['data' => []];
+    }
+    // Generate the links for the relationship.
+    $route_parameters = ['related' => $this->fieldName];
+    return [
+      'data' => $value,
+      'links' => [
+        'self' => $this->linkManager->getEntityLink(
+          $this->hostEntityId,
+          $this->resourceType,
+          $route_parameters,
+          'relationship'
+        ),
+        'related' => $this->linkManager->getEntityLink(
+          $this->hostEntityId,
+          $this->resourceType,
+          $route_parameters,
+          'related'
+        ),
+      ],
+    ];
+  }
+
+}
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..a65a218
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer\Value;
+
+/**
+ * @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/ConditionOption.php b/core/modules/jsonapi/src/Query/ConditionOption.php
new file mode 100644
index 0000000..36f0277
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/ConditionOption.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * A ConditionOption represents an option which can be applied to a query.
+ *
+ * @internal
+ */
+class ConditionOption implements QueryOptionInterface {
+
+  /**
+   * A unique key.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * A unique key representing the intended parent of this option.
+   *
+   * @var string|NULL
+   */
+  protected $parentId;
+
+  /**
+   * String representation of the entity field in to be checked.
+   *
+   * @var string
+   */
+  protected $field;
+
+  /**
+   * Value of the condition for the given field.
+   *
+   * @var string|string[]
+   */
+  protected $value;
+
+  /**
+   * Conditional operator with which to compare values.
+   *
+   * @var string
+   */
+  protected $operator;
+
+  /**
+   * The langcode of the field to check.
+   *
+   * @var string
+   */
+  protected $langcode;
+
+  /**
+   * Constructs a new ConditionOption.
+   *
+   * @param string $id
+   *   A unique string identifier for the option.
+   * @param string|\Drupal\jsonapi\Query\GroupOption $field
+   *   Either a field name or a GroupOption.
+   * @param mixed $value
+   *   Value for comparison.
+   * @param string $operator
+   *   Boolean operator.
+   * @param string $langcode
+   *   Language of entity to compare against.
+   */
+  public function __construct($id, $field, $value = NULL, $operator = NULL, $langcode = NULL, $parent_id = NULL) {
+    $this->id = $id;
+    $this->field = $field;
+    $this->value = $value;
+    $this->operator = $operator;
+    $this->langcode = $langcode;
+    $this->parentId = $parent_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    return $this->id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply($query) {
+    return $query->condition($this->field, $this->value, $this->operator, $this->langcode);
+  }
+
+  /**
+   * Returns the id of this option's parent.
+   *
+   * @return string|null
+   *   Either the id of its parent or NULL.
+   */
+  public function parentId() {
+    return $this->parentId;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/GroupOption.php b/core/modules/jsonapi/src/Query/GroupOption.php
new file mode 100644
index 0000000..d51a20c
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/GroupOption.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * A GroupOption can group other options before applying them to a query.
+ *
+ * @internal
+ */
+class GroupOption implements QueryOptionInterface, QueryOptionTreeItemInterface {
+
+  /**
+   * A unique key.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * A unique key representing a parent condition group.
+   *
+   * @var string
+   */
+  protected $parentGroup;
+
+  /**
+   * An array of QueryOptions.
+   *
+   * @var \Drupal\jsonapi\Query\QueryOptionInterface[]
+   */
+  protected $childOptions;
+
+  /**
+   * An array of GroupOptions.
+   *
+   * @var \Drupal\jsonapi\Query\GroupOption[]
+   */
+  protected $childGroups;
+
+  /**
+   * Conjunction of the groups conditions.
+   *
+   * @var string
+   */
+  protected $conjunction;
+
+  /**
+   * Constructs a new GroupOption.
+   *
+   * @param string $id
+   *   A unique string identifier for the option.
+   * @param string $conjunction
+   *   Conjunction of the groups conditions.
+   * @param string $parent_group
+   *   A unique key representing a parent condition group.
+   */
+  public function __construct($id, $conjunction = 'AND', $parent_group = NULL) {
+    $this->id = $id;
+    $this->conjunction = $conjunction;
+    $this->parentGroup = $parent_group;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    return $this->id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function parentId() {
+    return $this->parentGroup;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function insert($target_id, QueryOptionInterface $option) {
+    $find_proper_id = function ($child_id, $group_option) use ($target_id) {
+      if ($child_id) {
+        return $child_id;
+      };
+      return $group_option->hasChild($target_id) ?
+        $group_option->id() :
+        NULL;
+    };
+
+    if ($this->id() == $target_id) {
+      $prop = $option instanceof GroupOption ? 'childGroups' : 'childOptions';
+      $this->{$prop}[$option->id()] = $option;
+      return TRUE;
+    }
+    elseif ($proper_child = array_reduce($this->childGroups, $find_proper_id, NULL)) {
+      return $this->childGroups[$proper_child]->insert($target_id, $option);
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply($query) {
+    switch ($this->conjunction) {
+      case 'OR':
+        $group = $query->orConditionGroup();
+        break;
+
+      case 'AND':
+      default:
+        $group = $query->andConditionGroup();
+        break;
+    }
+
+    if (!empty($this->childOptions)) {
+      $group = array_reduce($this->childOptions, function ($group, $child) {
+        return $child->apply($group);
+      }, $group);
+    }
+
+    if (!empty($this->childGroups)) {
+      $group = array_reduce($this->childGroups, function ($group, $child) {
+        return $child->apply($group);
+      }, $group);
+    }
+
+    return $query->condition($group);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasChild($id) {
+    // Return FALSE if this node has no child.
+    if (!isset($this->childOptions) || empty($this->childOptions)) {
+      return FALSE;
+    }
+
+    // If any of the options have the specified id, return TRUE.
+    if (in_array($id, array_keys($this->childOptions))) {
+      return TRUE;
+    }
+
+    // If any child GroupOptions or their children have the id return TRUE.
+    return array_reduce($this->groupOptions, function ($has_child, $group) use ($id) {
+      // If we already know that we have the child, skip evaluation and return.
+      return $has_child || $group->id() == $id || $group->hasChild($id);
+    }, FALSE);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/OffsetPagerOption.php b/core/modules/jsonapi/src/Query/OffsetPagerOption.php
new file mode 100644
index 0000000..9e08cf3
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/OffsetPagerOption.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * @internal
+ */
+class OffsetPagerOption implements QueryOptionInterface {
+
+  /**
+   * The size.
+   *
+   * @var int
+   */
+  protected $size;
+
+  /**
+   * The offset.
+   *
+   * @var int
+   */
+  protected $offset;
+
+  /**
+   * Creates a PagerOption object.
+   *
+   * @param int $size
+   *   The maximum number of items to return.
+   * @param int $offset
+   *   The starting element.
+   */
+  public function __construct($size, $offset = 0) {
+    $this->size = $size;
+    $this->offset = $offset ?: 0;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    return 'offset_pager';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply($query) {
+    if (isset($this->offset) && isset($this->size)) {
+      // Request one extra entity to know if there is a next page.
+      $query->range($this->offset, $this->size + 1);
+      $query->addMetaData('pager_size', (int) $this->size);
+    }
+
+    return $query;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/QueryBuilder.php b/core/modules/jsonapi/src/Query/QueryBuilder.php
new file mode 100644
index 0000000..0e84c03
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/QueryBuilder.php
@@ -0,0 +1,363 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Routing\Param\Filter;
+use Drupal\jsonapi\Routing\Param\JsonApiParamInterface;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Routing\Param\Sort;
+
+/**
+ * @internal
+ */
+class QueryBuilder {
+
+  /**
+   * The entity type object that should be used for the query.
+   */
+  protected $entityType;
+
+  /**
+   * The options to build with which to build a query.
+   */
+  protected $options = [];
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The JSON API current context service.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * The field resolver service.
+   *
+   * @var \Drupal\jsonapi\Context\FieldResolver
+   */
+  protected $fieldResolver;
+
+  /**
+   * Contructs a new QueryBuilder object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   An instance of a QueryFactory.
+   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
+   *   An instance of the current context service.
+   * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
+   *   The field resolver service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, CurrentContext $current_context, FieldResolver $field_resolver) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->currentContext = $current_context;
+    $this->fieldResolver = $field_resolver;
+  }
+
+  /**
+   * Creates a new Entity Query.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to create a query.
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   *   The JSON API parameters.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   The new query.
+   */
+  public function newQuery(EntityTypeInterface $entity_type, array $params = []) {
+    $this->entityType = $entity_type;
+
+    $this->configureFromContext($params);
+
+    $query = $this->entityTypeManager
+      ->getStorage($this->entityType->id())
+      ->getQuery()
+      ->accessCheck(TRUE);
+
+    // This applies each option from the option tree to the query before
+    // returning it.
+    $applied_query = array_reduce($this->options, function ($query, $option) {
+      /* @var \Drupal\jsonapi\Query\QueryOptionInterface $option */
+      return $option->apply($query);
+    }, $query);
+
+    return $applied_query ? $applied_query : $query;
+  }
+
+  /**
+   * Configure the query from the current context and the provided parameters.
+   *
+   * To avoid using the global context so much use the passed in parameters
+   * over the ones in the current context.
+   *
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   *   The JSON API parameters.
+   */
+  protected function configureFromContext(array $params = []) {
+    // TODO: Explore the possibility to turn JsonApiParam into a plugin type.
+    $param_keys = [Filter::KEY_NAME, Sort::KEY_NAME];
+    foreach ($param_keys as $param_key) {
+      if (isset($params[$param_key])) {
+        $this->configureParam($param_key, $params[$param_key]);
+      }
+      elseif ($param = $this->currentContext->getJsonApiParameter($param_key)) {
+        $this->configureParam($param_key, $param);
+      }
+    }
+    // We always add a default pagination parameter.
+    $pager = isset($params[OffsetPage::KEY_NAME]) ?
+      $params[OffsetPage::KEY_NAME] :
+      new OffsetPage([]);
+    $this->configureParam(OffsetPage::KEY_NAME, $pager);
+  }
+
+  /**
+   * Configure a parameter based on the type parameter type.
+   *
+   * @param string $type
+   *   The parameter type.
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
+   *   The parameter to configure.
+   */
+  protected function configureParam($type, JsonApiParamInterface $param) {
+    switch ($type) {
+      case Filter::KEY_NAME:
+        $this->configureFilter($param);
+        break;
+
+      case Sort::KEY_NAME:
+        $this->configureSort($param);
+        break;
+
+      case OffsetPage::KEY_NAME:
+        $this->configurePager($param);
+        break;
+    }
+  }
+
+  /**
+   * Configures the query builder from a Filter parameter.
+   *
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
+   *   A Filter parameter from which to configure this query builder.
+   *
+   * @todo The nested closures passing parameters by reference may not be ideal.
+   */
+  protected function configureFilter(JsonApiParamInterface $param) {
+    $extracted = [];
+
+    foreach ($param->get() as $filter_index => $filter) {
+      foreach ($filter as $filter_type => $properties) {
+        switch ($filter_type) {
+          case Filter::CONDITION_KEY:
+            $extracted[] = $this->newCondtionOption($filter_index, $properties);
+            break;
+
+          case Filter::GROUP_KEY:
+            $extracted[] = $this->newGroupOption($filter_index, $properties);
+            break;
+
+          default:
+            throw new SerializableHttpException(
+              400,
+              sprintf('Invalid syntax in the filter parameter: %s.', $filter_index)
+            );
+        };
+      }
+    }
+
+    $this->buildTree($extracted);
+  }
+
+  /**
+   * Configures the query builder from a Sort parameter.
+   *
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
+   *   A Sort parameter from which to configure this query builder.
+   */
+  protected function configureSort(JsonApiParamInterface $param) {
+    $extracted = [];
+    foreach ($param->get() as $sort_index => $sort) {
+      $extracted[] = $this->newSortOption(sprintf('sort_%s', $sort_index), $sort);
+    }
+
+    $this->buildTree($extracted);
+  }
+
+  /**
+   * Configures the query builder from a Pager parameter.
+   *
+   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
+   *   A pager parameter from which to configure this query builder.
+   */
+  protected function configurePager(JsonApiParamInterface $param) {
+    $this->buildTree([$this->newPagerOption($param->get())]);
+  }
+
+  /**
+   * Returns a new ConditionOption.
+   *
+   * @param string $condition_id
+   *   A unique id for the option.
+   * @param array $properties
+   *   The condition properties.
+   *
+   * @return \Drupal\jsonapi\Query\ConditionOption
+   *   The condition object.
+   */
+  protected function newCondtionOption($condition_id, array $properties) {
+    $langcode_key = $this->getLangcodeKey();
+    $langcode = isset($properties[$langcode_key]) ? $properties[$langcode_key] : NULL;
+    $membership = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL;
+    $field = isset($properties[Filter::PATH_KEY]) ? $properties[Filter::PATH_KEY] : NULL;
+    $value = isset($properties[Filter::VALUE_KEY]) ? $properties[Filter::VALUE_KEY] : NULL;
+    $operator = isset($properties[Filter::OPERATOR_KEY]) ? $properties[Filter::OPERATOR_KEY] : NULL;
+    return new ConditionOption(
+      $condition_id,
+      $this->fieldResolver->resolveInternal($field),
+      $value,
+      $operator,
+      $langcode,
+      $membership
+    );
+  }
+
+  /**
+   * Returns a new GroupOption.
+   *
+   * @param string $identifier
+   *   A unique id for the option.
+   * @param array $properties
+   *   The group properties.
+   *
+   * @return \Drupal\jsonapi\Query\GroupOption
+   *   The group object.
+   */
+  protected function newGroupOption($identifier, array $properties) {
+    $parent_group = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL;
+    $conjunction = isset($properties[Filter::CONJUNCTION_KEY]) ? $properties[Filter::CONJUNCTION_KEY] : NULL;
+    return new GroupOption($identifier, $conjunction, $parent_group);
+  }
+
+  /**
+   * Returns a new SortOption.
+   *
+   * @param string $identifier
+   *   A unique id for the option.
+   * @param array $properties
+   *   The sort properties.
+   *
+   * @return \Drupal\jsonapi\Query\SortOption
+   *   The sort object.
+   */
+  protected function newSortOption($identifier, array $properties) {
+    $field = isset($properties[Sort::FIELD_KEY]) ? $properties[Sort::FIELD_KEY] : NULL;
+    $direction = isset($properties[Sort::DIRECTION_KEY]) ? $properties[Sort::DIRECTION_KEY] : NULL;
+    $langcode = isset($properties[Sort::LANGUAGE_KEY]) ? $properties[Sort::LANGUAGE_KEY] : NULL;
+    return new SortOption(
+      $identifier,
+      $this->fieldResolver->resolveInternal($field),
+      $direction,
+      $langcode
+    );
+  }
+
+  /**
+   * Returns a new SortOption.
+   *
+   * @param array $properties
+   *   The pager properties.
+   *
+   * @return \Drupal\jsonapi\Query\OffsetPagerOption
+   *   The sort object.
+   */
+  protected function newPagerOption(array $properties) {
+    // Add defaults to avoid unset warnings.
+    $properties += [
+      'limit' => NULL,
+      'offset' => 0,
+    ];
+    return new OffsetPagerOption($properties['limit'], $properties['offset']);
+  }
+
+  /**
+   * Builds a tree of QueryOptions.
+   *
+   * @param \Drupal\jsonapi\Query\QueryOptionInterface[] $options
+   *   An array of QueryOptions.
+   */
+  protected function buildTree(array $options) {
+    $remaining = $options;
+    while (!empty($remaining)) {
+      $insert = array_pop($remaining);
+      if (method_exists($insert, 'parentId') && $parent_id = $insert->parentId()) {
+        if (!$this->insert($parent_id, $insert)) {
+          array_unshift($remaining, $insert);
+        }
+      }
+      else {
+        $this->options[$insert->id()] = $insert;
+      }
+    }
+  }
+
+  /**
+   * Inserts a QueryOption into the appropriate child QueryOption.
+   *
+   * @param string $target_id
+   *   Unique ID of the intended QueryOption parent.
+   * @param \Drupal\jsonapi\Query\QueryOptionInterface $option
+   *   The QueryOption to insert.
+   *
+   * @return bool
+   *   Whether the option could be inserted or not.
+   */
+  protected function insert($target_id, QueryOptionInterface $option) {
+    if (!empty($this->options)) {
+      $find_target_child = function ($child, QueryOptionInterface $my_option) use ($target_id) {
+        if ($child) {
+          return $child;
+        }
+        if (
+          $my_option->id() == $target_id ||
+          (method_exists($my_option, 'hasChild') && $my_option->hasChild($target_id))
+        ) {
+          return $my_option->id();
+        }
+        return FALSE;
+      };
+
+      if ($appropriate_child = array_reduce($this->options, $find_target_child, NULL)) {
+        return $this->options[$appropriate_child]->insert($target_id, $option);
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * Get the language code key.
+   *
+   * @return string
+   *   The key.
+   */
+  protected function getLangcodeKey() {
+    $entity_type_id = $this->currentContext->getResourceType()
+      ->getEntityTypeId();
+    return $this->entityTypeManager
+      ->getDefinition($entity_type_id)
+      ->getKey('langcode');
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Query/QueryOptionInterface.php b/core/modules/jsonapi/src/Query/QueryOptionInterface.php
new file mode 100644
index 0000000..91110f0
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/QueryOptionInterface.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * @internal
+ */
+interface QueryOptionInterface {
+
+  /**
+   * Returns a unique id for this query.
+   *
+   * @return string
+   *   The ID for the query.
+   */
+  public function id();
+
+  /**
+   * Receives a QueryInterface and applies the current QueryOption to it.
+   *
+   * @param \Drupal\Core\Entity\Query\QueryInterface|\Drupal\Core\Entity\Query\ConditionInterface $query
+   *   A query or condition group to which this option should be applied.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface|\Drupal\Core\Entity\Query\ConditionInterface
+   *   A query or condition with the current option applied to it.
+   */
+  public function apply($query);
+
+}
diff --git a/core/modules/jsonapi/src/Query/QueryOptionTreeItemInterface.php b/core/modules/jsonapi/src/Query/QueryOptionTreeItemInterface.php
new file mode 100644
index 0000000..102921c
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/QueryOptionTreeItemInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * @internal
+ */
+interface QueryOptionTreeItemInterface {
+
+  /**
+   * Insert the child into this object or one if its children objects.
+   *
+   * @param string $target_id
+   *   The QueryOption id of the intended parent.
+   * @param \Drupal\jsonapi\Query\QueryOptionInterface $option
+   *   The QueryOption to insert.
+   *
+   * @return bool
+   *   Whether or not the QueryOption could be inserted.
+   */
+  public function insert($target_id, QueryOptionInterface $option);
+
+  /**
+   * Returns whether or the given id is a (grand)child of the object.
+   */
+  public function hasChild($id);
+
+}
diff --git a/core/modules/jsonapi/src/Query/SortOption.php b/core/modules/jsonapi/src/Query/SortOption.php
new file mode 100644
index 0000000..54901f8
--- /dev/null
+++ b/core/modules/jsonapi/src/Query/SortOption.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+/**
+ * @internal
+ */
+class SortOption implements QueryOptionInterface {
+
+  /**
+   * A unique key.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The field by which to sort.
+   *
+   * @var string
+   */
+  protected $field;
+
+  /**
+   * The direction of the sort.
+   *
+   * @var string
+   */
+  protected $direction;
+
+  /**
+   * The langcode for the sort.
+   *
+   * @var string
+   */
+  protected $langcode;
+
+  /**
+   * Creates a SortOption object.
+   *
+   * @param string $id
+   *   An identifier for the sort options.
+   * @param string $field
+   *   The field by which to sort.
+   * @param string $field
+   *   The direction for the sort.
+   * @param string $langcode
+   *   The language variant of the field to sort by.
+   */
+  public function __construct($id, $field, $direction = 'ASC', $langcode = NULL) {
+    $this->id = $id;
+    $this->field = $field;
+    $this->direction = $direction;
+    $this->langcode = $langcode;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    return $this->id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply($query) {
+    return $query->sort($this->field, $this->direction);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/RequestCacheabilityDependency.php b/core/modules/jsonapi/src/RequestCacheabilityDependency.php
new file mode 100644
index 0000000..2825c7b
--- /dev/null
+++ b/core/modules/jsonapi/src/RequestCacheabilityDependency.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\UnchangingCacheableDependencyTrait;
+
+/**
+ * @internal
+ */
+class RequestCacheabilityDependency implements CacheableDependencyInterface {
+
+  use UnchangingCacheableDependencyTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return array_map(function ($param_name) {
+      return sprintf('url.query_args:%s', $param_name);
+    }, $this::getQueryParamCacheContextList());
+  }
+
+  /**
+   * Builds the list of URL query parameter names for the cache context.
+   *
+   * @return string[]
+   *   The list of parameter names that vary the cache entry.
+   */
+  protected static function getQueryParamCacheContextList() {
+    return ['filter', 'sort', 'page', 'fields', 'include'];
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Resource/EntityCollection.php b/core/modules/jsonapi/src/Resource/EntityCollection.php
new file mode 100644
index 0000000..d173abc
--- /dev/null
+++ b/core/modules/jsonapi/src/Resource/EntityCollection.php
@@ -0,0 +1,89 @@
+<?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;
+
+  /**
+   * 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);
+  }
+
+  /**
+   * 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..b421df8
--- /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, ResourceResponseInterface {
+
+  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, $headers = array()) {
+    $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/ResourceResponseInterface.php b/core/modules/jsonapi/src/ResourceResponseInterface.php
new file mode 100644
index 0000000..3ad91e6
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceResponseInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\jsonapi;
+
+/**
+ * Defines a common interface for resource responses.
+ *
+ * @internal
+ */
+interface ResourceResponseInterface {
+
+  /**
+   * Returns response data that should be serialized.
+   *
+   * @return mixed
+   *   Response data that should be serialized.
+   */
+  public function getResponseData();
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceType.php b/core/modules/jsonapi/src/ResourceType/ResourceType.php
new file mode 100644
index 0000000..ae929c4
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceType.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Value object containing all metadata for a JSON API resource type.
+ *
+ * Used to generate routes (collection, individual, et cetera), generate
+ * relationship links, and so on.
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ *
+ * @internal
+ */
+class ResourceType {
+
+  /**
+   * The entity type ID.
+   *
+   * @var string
+   */
+  protected $entityTypeId;
+
+  /**
+   * The bundle ID.
+   *
+   * @var string
+   */
+  protected $bundle;
+
+  /**
+   * The type name.
+   *
+   * @var string
+   */
+  protected $typeName;
+
+  /**
+   * The class to which a payload converts to.
+   *
+   * @var string
+   */
+  protected $deserializationTargetClass;
+
+  /**
+   * 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;
+  }
+
+  /**
+   * 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.
+   */
+  public function __construct($entity_type_id, $bundle, $deserialization_target_class) {
+    $this->entityTypeId = $entity_type_id;
+    $this->bundle = $bundle;
+    $this->deserializationTargetClass = $deserialization_target_class;
+
+    $this->typeName = sprintf('%s--%s', $this->entityTypeId, $this->bundle);
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
new file mode 100644
index 0000000..876e099
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+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 {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The bundle manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
+   */
+  protected $bundleManager;
+
+  /**
+   * All JSON API resource types.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType[]
+   */
+  protected $all = [];
+
+  /**
+   * Instantiates a ResourceTypeRepository object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_manager
+   *   The bundle manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->bundleManager = $bundle_manager;
+  }
+
+  /**
+   * 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() {
+    if (!$this->all) {
+      $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions());
+      foreach ($entity_type_ids as $entity_type_id) {
+        $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) {
+          return new ResourceType(
+            $entity_type_id,
+            $bundle,
+            $this->entityTypeManager->getDefinition($entity_type_id)->getClass()
+          );
+        }, array_keys($this->bundleManager->getBundleInfo($entity_type_id))));
+      }
+    }
+    return $this->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_id
+   *   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) {
+    if (empty($entity_type_id)) {
+      throw new PreconditionFailedHttpException('Server error. The current route is malformed.');
+    }
+    foreach ($this->all(TRUE) as $resource) {
+      if ($resource->getEntityTypeId() == $entity_type_id && $resource->getBundle() == $bundle) {
+        return $resource;
+      }
+    }
+    return NULL;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
new file mode 100644
index 0000000..b0274d9
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Routing\Enhancer\RouteEnhancerInterface;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Routing\Param\Filter;
+use Drupal\jsonapi\Routing\Param\Sort;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @internal
+ */
+class JsonApiParamEnhancer implements RouteEnhancerInterface {
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * Instantiates a JsonApiParamEnhancer object.
+   *
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The field manager.
+   */
+  public function __construct(EntityFieldManagerInterface $field_manager) {
+    $this->fieldManager = $field_manager;
+  }
+
+  /**
+   * {@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 = [];
+    if ($request->query->has('filter')) {
+      $entity_type_id = $defaults[RouteObjectInterface::ROUTE_OBJECT]->getRequirement('_entity_type');
+      $options['filter'] = new Filter($request->query->get('filter'), $entity_type_id, $this->fieldManager);
+    }
+    if ($request->query->has('sort')) {
+      $options['sort'] = new Sort($request->query->get('sort'));
+    }
+    if ($request->query->has('page')) {
+      $options['page'] = new OffsetPage($request->query->get('page'), OffsetPage::$maxSize);
+    }
+    else {
+      $options['page'] = new OffsetPage(['start' => 0, 'limit' => OffsetPage::$maxSize], OffsetPage::$maxSize);
+    }
+    $defaults['_json_api_params'] = $options;
+    return $defaults;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Param/Filter.php b/core/modules/jsonapi/src/Routing/Param/Filter.php
new file mode 100644
index 0000000..2033473
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Param/Filter.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\jsonapi\Routing\Param;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+
+/**
+ * @internal
+ */
+class Filter extends JsonApiParamBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  const KEY_NAME = 'filter';
+
+  /**
+   * 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 field key in the filter condition: filter[lorem][condition][<field>].
+   *
+   * @var string
+   */
+  const PATH_KEY = 'path';
+
+  /**
+   * The value key in the filter condition: filter[lorem][condition][<value>].
+   *
+   * @var string
+   */
+  const VALUE_KEY = 'value';
+
+  /**
+   * The operator key in the condition: filter[lorem][condition][<operator>].
+   *
+   * @var string
+   */
+  const OPERATOR_KEY = 'operator';
+
+  /**
+   * The conjunction key in the condition: filter[lorem][group][<conjunction>].
+   *
+   * @var string
+   */
+  const CONJUNCTION_KEY = 'conjunction';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand() {
+    // We should always get an array for the filter.
+    if (!is_array($this->original)) {
+      throw new SerializableHttpException(400, 'Incorrect value passed to the filter parameter.');
+    }
+
+    $expanded = [];
+    foreach ($this->original as $filter_index => $filter_item) {
+      $expanded[$filter_index] = $this->expandItem($filter_index, $filter_item);
+    }
+    return $expanded;
+  }
+
+  /**
+   * Expands a filter item in case a shortcut was used.
+   *
+   * Possible cases for the conditions:
+   *   1. filter[uuid][value]=1234.
+   *   2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234.
+   *   3. filter[uuid][condition][value]=1234.
+   *   4. filter[uuid][value]=1234&filter[uuid][group]=my_group.
+   *
+   * @param string $filter_index
+   *   The index.
+   * @param array $filter_item
+   *   The raw filter item.
+   *
+   * @return array
+   *   The expanded filter item.
+   */
+  protected function expandItem($filter_index, array $filter_item) {
+    if (isset($filter_item[static::VALUE_KEY])) {
+      if (!isset($filter_item[static::PATH_KEY])) {
+        $filter_item[static::PATH_KEY] = $filter_index;
+      }
+      $filter_item = [
+        static::CONDITION_KEY => $filter_item,
+      ];
+
+      if (!isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY])) {
+        $filter_item[static::CONDITION_KEY][static::OPERATOR_KEY] = '=';
+      }
+    }
+
+    return $filter_item;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php b/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php
new file mode 100644
index 0000000..21cb503
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\jsonapi\Routing\Param;
+
+/**
+ * @internal
+ */
+class JsonApiParamBase implements JsonApiParamInterface {
+
+  /**
+   * The original data.
+   *
+   * @var string|string[]
+   */
+  protected $original;
+
+  /**
+   * The expanded data.
+   *
+   * @var string|string[]
+   */
+  protected $data;
+
+  /**
+   * Create a parameter object.
+   *
+   * @param string|string[] $original
+   *   The user generated data.
+   */
+  public function __construct($original) {
+    $this->original = $original;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get() {
+    if (!$this->data) {
+      $this->data = $this->expand();
+    }
+    return $this->data;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOriginal() {
+    return $this->original;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getKey() {
+    return static::KEY_NAME;
+  }
+
+  /**
+   * Apply all necessary defaults and transformations to the parameter.
+   *
+   * @return string|string[]
+   *   The expanded data.
+   */
+  protected function expand() {
+    // The base implementation does no expansion.
+    return $this->original;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php b/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php
new file mode 100644
index 0000000..695aa37
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\jsonapi\Routing\Param;
+
+/**
+ * @internal
+ */
+interface JsonApiParamInterface {
+
+  /**
+   * The key name.
+   *
+   * This must be redefined with a unique value in each class that extends
+   * from JsonApiParamInterface.
+   *
+   * @var string
+   */
+  const KEY_NAME = NULL;
+
+  /**
+   * Gets the original parsed query string param.
+   *
+   * @return string|string[]
+   *   The original value.
+   */
+  public function getOriginal();
+
+  /**
+   * Gets the expanded value with defaults.
+   *
+   * @return string|string[]
+   *   The query string value.
+   */
+  public function get();
+
+  /**
+   * Gets the key of the parameter.
+   *
+   * @return string
+   *   The key.
+   */
+  public function getKey();
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Param/OffsetPage.php b/core/modules/jsonapi/src/Routing/Param/OffsetPage.php
new file mode 100644
index 0000000..e2b3feb
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Param/OffsetPage.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\jsonapi\Routing\Param;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+
+/**
+ * @internal
+ */
+class OffsetPage extends JsonApiParamBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  const KEY_NAME = 'page';
+
+  /**
+   * Max size.
+   *
+   * @var int
+   */
+  public static $maxSize = 50;
+
+  /**
+   * Instantiates an OffsetPage object.
+   *
+   * @param string|\string[] $original
+   *   The original user generated data.
+   * @param int $max_size
+   *   The maximum size for the pager.
+   */
+  public function __construct($original, $max_size = NULL) {
+    parent::__construct($original);
+    if ($max_size) {
+      static::$maxSize = $max_size;
+    }
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand() {
+    if (!is_array($this->original)) {
+      throw new SerializableHttpException(400, 'The page parameter needs to be an array.');
+    }
+    $output = $this->original + ['limit' => static::$maxSize];
+    $output['limit'] = $output['limit'] > static::$maxSize ?
+      static::$maxSize :
+      $output['limit'];
+    return $output;
+  }
+
+  /**
+   * Returns the current offset.
+   *
+   * @return int
+   */
+  public function getOffset() {
+    $data = $this->get();
+    return isset($data['offset']) ? $data['offset'] : 0;
+  }
+
+  /**
+   * Returns the page size.
+   *
+   * @return int
+   */
+  public function getSize() {
+    $data = $this->get();
+    $size = isset($data['limit']) ? $data['limit'] : static::$maxSize;
+    return $size > static::$maxSize ? static::$maxSize : $size;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/Param/Sort.php b/core/modules/jsonapi/src/Routing/Param/Sort.php
new file mode 100644
index 0000000..e0aac10
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Param/Sort.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Drupal\jsonapi\Routing\Param;
+
+use Drupal\jsonapi\Exception\SerializableHttpException;
+
+/**
+ * @internal
+ */
+class Sort extends JsonApiParamBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  const KEY_NAME = 'sort';
+
+  /**
+   * The field key in the sort parameter: sort[lorem][<field>].
+   *
+   * @var string
+   */
+  const FIELD_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 conjunction key in the condition: filter[lorem][group][<conjunction>].
+   *
+   * @var string
+   */
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand() {
+    $sort = $this->original;
+
+    if (empty($sort)) {
+      throw new SerializableHttpException(400, '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[static::DIRECTION_KEY] = 'DESC';
+        $sort[static::FIELD_KEY] = substr($field, 1);
+      }
+      else {
+        $sort[static::DIRECTION_KEY] = 'ASC';
+        $sort[static::FIELD_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 = [
+      static::DIRECTION_KEY => 'ASC',
+      static::LANGUAGE_KEY => NULL,
+    ];
+
+    if (!isset($sort_item[static::FIELD_KEY])) {
+      throw new SerializableHttpException(400, 'You need to provide a field name for the sort parameter.');
+    }
+
+    $expected_keys = [
+      static::FIELD_KEY,
+      static::DIRECTION_KEY,
+      static::LANGUAGE_KEY,
+    ];
+
+    $expanded = array_merge($defaults, $sort_item);
+
+    // Verify correct sort keys.
+    if (count(array_diff($expected_keys, array_keys($expanded))) > 0) {
+      throw new SerializableHttpException(400, 'You have provided an invalid set of sort keys.');
+    }
+
+    return $expanded;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Routing/RouteEnhancer.php b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
new file mode 100644
index 0000000..9ee019d
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/RouteEnhancer.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Routing\Enhancer\RouteEnhancerInterface;
+use Drupal\jsonapi\Exception\SerializableHttpException;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @internal
+ */
+class RouteEnhancer implements RouteEnhancerInterface {
+
+  /**
+   * {@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 SerializableHttpException(404, 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..2ee225c
--- /dev/null
+++ b/core/modules/jsonapi/src/Routing/Routes.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\jsonapi\Routing;
+
+use Drupal\Core\Authentication\AuthenticationCollectorInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
+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 = '\Drupal\jsonapi\Controller\RequestHandler::handle';
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+   */
+  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\ResourceTypeRepository $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param \Drupal\Core\Authentication\AuthenticationCollectorInterface $auth_collector
+   *   The authentication provider collector.
+   */
+  public function __construct(ResourceTypeRepository $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\ResourceTypeRepository $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);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function routes() {
+    $collection = new RouteCollection();
+    foreach ($this->resourceTypeRepository->all() as $resource_type) {
+      $route_base_path = sprintf('/jsonapi/%s/%s', $resource_type->getEntityTypeId(), $resource_type->getBundle());
+      $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', $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', $resource_type->getBundle())
+        ->setRequirement('_permission', 'access content')
+        ->setRequirement('_format', 'api_json')
+        ->setRequirement('_custom_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', $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', $resource_type->getBundle())
+        ->setRequirement('_permission', 'access content')
+        ->setRequirement('_format', 'api_json')
+        ->setRequirement('_custom_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', $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', $resource_type->getBundle())
+        ->setRequirement('_permission', 'access content')
+        ->setRequirement('_format', 'api_json')
+        ->setRequirement('_custom_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', $resource_type->getEntityTypeId())
+        ->setRequirement('_bundle', $resource_type->getBundle())
+        ->setRequirement('_permission', 'access content')
+        ->setRequirement('_format', 'api_json')
+        ->setRequirement('_custom_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/tests/phpunit.xml b/core/modules/jsonapi/tests/phpunit.xml
new file mode 100644
index 0000000..b3bff7a
--- /dev/null
+++ b/core/modules/jsonapi/tests/phpunit.xml
@@ -0,0 +1,18 @@
+<!--?xml version="1.0" encoding="UTF-8"?-->
+
+<phpunit colors="true">
+  <testsuites>
+    <testsuite name="jsonapi">
+      <directory>./src/</directory>
+    </testsuite>
+  </testsuites>
+  <!-- Filter for coverage reports. -->
+  <filter>
+    <blacklist>
+      <directory>./vendor</directory>
+    </blacklist>
+    <whitelist>
+      <directory>../src</directory>
+    </whitelist>
+  </filter>
+</phpunit>
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..acfbf8d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php
@@ -0,0 +1,768 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+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\jsonapi\Routing\Param\OffsetPage;
+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;
+
+/**
+ * @group jsonapi
+ */
+class JsonApiFunctionalTest extends BrowserTestBase {
+
+  use EntityReferenceTestTrait;
+  use ImageFieldCreationTrait;
+
+  public static $modules = [
+    'basic_auth',
+    'jsonapi',
+    'serialization',
+    'node',
+    'image',
+    'taxonomy',
+    'link',
+  ];
+
+  /**
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * @var \Drupal\user\Entity\User
+   */
+  protected $userCanViewProfiles;
+
+  /**
+   * @var \Drupal\node\Entity\Node[]
+   */
+  protected $nodes = [];
+
+  /**
+   * @var \Drupal\taxonomy\Entity\Term[]
+   */
+  protected $tags = [];
+
+  /**
+   * @var \Drupal\file\Entity\File[]
+   */
+  protected $files = [];
+
+  /**
+   * @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(array(
+        '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');
+    }
+
+    FieldStorageConfig::create(array(
+      '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();
+
+    $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();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function drupalGet($path, array $options = array(), array $headers = array()) {
+    // Make sure we don't forget the format parameter.
+    $options += ['query' => []];
+    $options['query'] += ['_format' => 'api_json'];
+
+    return parent::drupalGet($path, $options, $headers);
+  }
+
+  /**
+   * 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.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   */
+  protected function request($method, Url $url, array $request_options) {
+    $url->setOption('query', ['_format' => 'api_json']);
+    try {
+      $response = $this->httpClient->request($method, $url->toString(), $request_options);
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+    }
+    catch (ServerException $e) {
+      $response = $e->getResponse();
+    }
+
+    return $response;
+  }
+
+  /**
+   * Test the GET method.
+   */
+  public function testRead() {
+    $this->createDefaultContent(60, 5, TRUE, TRUE);
+    // 1. Load all articles (1st page).
+    $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertEquals(OffsetPage::$maxSize, 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::$maxSize, count($collection_output['data']));
+    $this->assertContains('page[offset]=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[offset]=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']);
+    // 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']);
+    // 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']
+    );
+    // 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->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']));
+  }
+
+  /**
+   * Test POST, PATCH and DELETE.
+   */
+  public function testWrite() {
+    $this->createDefaultContent(0, 3, FALSE, FALSE);
+    // 1. Successful post.
+    $collection_url = Url::fromRoute('jsonapi.node--article.collection');
+    $body = [
+      'data' => [
+        'type' => 'node--article',
+        'attributes' => [
+          'langcode' => 'en',
+          'title' => 'My custom title',
+          'status' => '1',
+          'promote' => '1',
+          'sticky' => '0',
+          'default_langcode' => '1',
+          'body' => [
+            'value' => 'Custom value',
+            'format' => 'plain_text',
+            'summary' => 'Custom summary',
+          ],
+        ],
+        'relationships' => [
+          'type' => [
+            'data' => [
+              'type' => 'node_type--node_type',
+              'id' => 'article',
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'type' => 'user--user',
+              'id' => '1',
+            ],
+          ],
+          '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']));
+    // 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']);
+    // 3. Missing Content-Type error.
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($body),
+      'auth' => [$this->user->getUsername(), $this->user->pass_raw],
+    ]);
+    $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']['attributes']['nid'] = 1;
+    $response = $this->request('POST', $collection_url, [
+      'body' => Json::encode($invalid_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(500, $response->getStatusCode());
+    $this->assertNotEmpty($created_response['errors']);
+    $this->assertEquals('Internal Server Error', $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'],
+    ]);
+    $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']);
+    // 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']);
+    // 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).', $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'],
+    ]);
+    $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'],
+    ]);
+    $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(0, $updated_response['errors'][$i]['code']);
+    }
+    $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'],
+    ]);
+    $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());
+  }
+
+  /**
+   * 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.
+   */
+  protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link) {
+    $random = $this->getRandomGenerator();
+    for ($created_tags = 0; $created_tags < $num_tags; $created_tags++) {
+      $term = Term::create([
+        'vid' => 'tags',
+        'name' => $random->name(),
+      ]);
+      $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()];
+      }
+      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'
+          ),
+        ];
+      }
+      $this->nodes[] = $this->createNode($values);
+    }
+    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/Kernel/Controller/EntityResourceTest.php b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
new file mode 100644
index 0000000..93e8609
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -0,0 +1,858 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Controller;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Routing\CurrentRouteMatch;
+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\Routing\Param\Filter;
+use Drupal\jsonapi\Routing\Param\Sort;
+use Drupal\jsonapi\Routing\Param\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\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Controller\EntityResource
+ * @group jsonapi
+ */
+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
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   */
+  public function testGetIndividualDenied() {
+    $role = Role::load(RoleInterface::ANONYMOUS_ID);
+    $role->revokePermission('access content');
+    $role->save();
+    $entity_resource = $this->buildEntityResource('node', 'article');
+    $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(['type' => ['value' => 'article']], 'node_type', $field_manager);
+    // 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'),
+      $this->container->get('jsonapi.query_builder'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type')
+    );
+
+    // 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('-type');
+    // 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'),
+      $this->container->get('jsonapi.query_builder'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type')
+    );
+
+    // 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(['offset' => 1, 'limit' => 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'),
+      $this->container->get('jsonapi.query_builder'),
+      $field_manager,
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type')
+    );
+
+    // 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(
+      ['uuid' => ['value' => 'invalid']],
+      'node',
+      $this->container->get('entity_field.manager')
+    );
+    $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');
+    $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());
+
+    // to-many relationship.
+    $response = $entity_resource->getRelated($this->user, 'roles', new Request());
+    $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response
+      ->getResponseData());
+    $this->assertInstanceOf(EntityCollection::class, $response
+      ->getResponseData()
+      ->getData());
+    $this->assertEquals([
+      'config:user.role.test_role_one',
+      'config:user.role.test_role_two',
+      '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');
+    $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 ::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
+   * @expectedException \Drupal\Core\Config\ConfigException
+   */
+  public function testPatchIndividualFailedConfig($values) {
+    $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');
+    $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');
+    $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');
+    $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());
+  }
+
+  /**
+   * 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.
+   *
+   * @return \Drupal\jsonapi\Controller\EntityResource
+   *   The resource.
+   */
+  protected function buildEntityResource($entity_type_id, $bundle) {
+    // 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);
+
+    return new EntityResource(
+      new ResourceType($entity_type_id, $bundle, NULL),
+      $this->container->get('entity_type.manager'),
+      $this->container->get('jsonapi.query_builder'),
+      $this->container->get('entity_field.manager'),
+      $current_context,
+      $this->container->get('plugin.manager.field.field_type')
+    );
+  }
+
+}
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..080b9b1
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Field;
+
+use Drupal\file\Entity\File;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Field\FileDownloadUrl
+ * @group jsonapi
+ */
+class FileDownloadUrlTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'jsonapi',
+    'file',
+    'serialization',
+    'user',
+  ];
+
+  /**
+   * @var \Drupal\file\Entity\File
+   */
+  protected $file;
+
+  /**
+   * @var string
+   *   The test filename.
+   */
+  protected $filename = 'druplicon.txt';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('file');
+    $this->installSchema('file', array('file_usage'));
+
+    // Create a new file entity.
+    $this->file = File::create(array(
+      '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);
+    });
+  }
+
+}
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..96a1100
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel;
+
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @internal
+ */
+abstract class JsonapiKernelTestBase extends KernelTestBase {
+
+  /**
+   * 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', $handler_settings = array(), $cardinality = 1) {
+    // Look for or add the specified field to the requested entity bundle.
+    if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
+      FieldStorageConfig::create(array(
+        'field_name' => $field_name,
+        'type' => 'entity_reference',
+        'entity_type' => $entity_type,
+        'cardinality' => $cardinality,
+        'settings' => array(
+          'target_type' => $target_entity_type,
+        ),
+      ))->save();
+    }
+    if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
+      FieldConfig::create(array(
+        'field_name' => $field_name,
+        'entity_type' => $entity_type,
+        'bundle' => $bundle,
+        'label' => $field_label,
+        'settings' => array(
+          'handler' => $selection_handler,
+          'handler_settings' => $handler_settings,
+        ),
+      ))->save();
+    }
+  }
+
+}
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..9316bcf
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,580 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer;
+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\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
+ */
+class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'jsonapi',
+    'field',
+    'node',
+    'serialization',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+  ];
+
+  /**
+   * 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');
+    // Add the additional table schemas.
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    $type = NodeType::create([
+      'type' => 'article',
+    ]);
+    $type->save();
+    $this->createEntityReferenceField(
+      'node',
+      'article',
+      'field_tags',
+      'Tags',
+      'taxonomy_term',
+      'default',
+      ['target_bundles' => ['tags']],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $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->node = Node::create([
+      'title' => 'dummy_title',
+      'type' => 'article',
+      'uid' => 1,
+      'field_tags' => [
+        ['target_id' => $this->term1->id()],
+        ['target_id' => $this->term2->id()],
+      ],
+    ]);
+
+    $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
+   */
+  public function testNormalize() {
+    list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $request->query = new ParameterBag([
+      'fields' => [
+        'node--article' => 'title,type,uid,field_tags',
+        'user--user' => 'name',
+      ],
+      'include' => 'uid,field_tags',
+    ]);
+
+    $response = new ResourceResponse();
+    $normalized = $this
+      ->container
+      ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+      ->normalize(
+        new JsonApiDocumentTopLevel($this->node),
+        'api_json',
+        [
+          'request' => $request,
+          'resource_type' => $resource_type,
+          'cacheable_metadata' => $response->getCacheableMetadata(),
+        ]
+      );
+    $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->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(
+      'Access checks failed for entity user:' . $this->user->id() . '.',
+      $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->assertSame(
+      ['node:1', 'taxonomy_term:1', 'taxonomy_term:2'],
+      $response->getCacheableMetadata()->getCacheTags()
+    );
+    $this->assertSame(
+      Cache::PERMANENT,
+      $response->getCacheableMetadata()->getCacheMaxAge()
+    );
+  }
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalizeRelated() {
+    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
+      ->container
+      ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+      ->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
+      ->container
+      ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+      ->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->assertSame(
+      ['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('serializer')
+      ->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
+      ->container
+      ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+      ->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 = '{"type":"article", "data":{"attributes":{"title":"Testing article"}}}';
+
+    list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id');
+    $node = $this
+      ->container
+      ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+      ->denormalize(Json::decode($payload), JsonApiDocumentTopLevelNormalizer::class, '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(),
+        ],
+      ],
+      // 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
+        ->container
+        ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi')
+        ->denormalize(Json::decode($payload), JsonApiDocumentTopLevelNormalizer::class, '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();
+      $this->assertEquals($expected['tag_ids'][0], $tags[0]['target_id']);
+      if (!empty($expected['tag_ids'][1])) {
+        $this->assertEquals($expected['tag_ids'][1], $tags[1]['target_id']);
+      }
+    }
+  }
+
+  /**
+   * We cannot use a PHPUnit data provider because our data depends on $this.
+   *
+   * @param array $options
+   *
+   * @return array
+   *   The test data.
+   */
+  protected function denormalizeUuidProviderBuilder($options) {
+    list($input, $expected) = $options;
+    list($input_tag_uuids, $input_user_uuid) = $input;
+    list($expected_tag_ids, $expected_user_id) = $expected;
+
+    return [
+      [
+        'type' => 'node--article',
+        'data' => [
+          'attributes' => [
+            'title' => 'Testing article',
+            'id' => '33095485-70D2-4E51-A309-535CC5BC0115',
+          ],
+          'relationships' => [
+            'uid' => [
+              'data' => [
+                'type' => 'user--user',
+                'id' => $input_user_uuid,
+              ],
+            ],
+            'field_tags' => [
+              'data' => [
+                [
+                  'type' => 'taxonomy_term--tags',
+                  'id' => $input_tag_uuids[0],
+                ],
+                [
+                  'type' => 'taxonomy_term--tags',
+                  'id' => $input_tag_uuids[1],
+                ],
+              ],
+            ],
+          ],
+        ],
+      ],
+      [
+        'tag_ids' => $expected_tag_ids,
+        'user_id' => $expected_user_id,
+      ],
+    ];
+  }
+
+  /**
+   * Generates the prophecies for the mocked entity request.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type. Ex: node.
+   * @param string $bundle
+   *   The bundle. Ex: article.
+   *
+   * @return array
+   *   A numeric array containing the request and the ResourceType.
+   */
+  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('serializer');
+
+    return [$request, $resource_type];
+  }
+
+}
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..f2cd644
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
@@ -0,0 +1,97 @@
+<?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
+ */
+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/Unit/Access/CustomParameterNamesTest.php b/core/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php
new file mode 100644
index 0000000..7ba1845
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Access;
+
+use Drupal\jsonapi\Access\CustomParameterNames;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Access\CustomParameterNames
+ * @group jsonapi
+ */
+class CustomParameterNamesTest extends \PHPUnit_Framework_TestCase {
+
+  /**
+   * @dataProvider providerTestJsonApiParamsValidation
+   * @covers ::access
+   * @covers ::validate
+   */
+  public function testJsonApiParamsValidation($name, $valid) {
+    $access_checker = new CustomParameterNames();
+
+    $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());
+    }
+  }
+
+  public function providerTestJsonApiParamsValidation() {
+    // 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];
+
+    $unsafe_chars = [
+      '+',
+      ',',
+      '.',
+      '[',
+      ']',
+      '!',
+      '”',
+      '#',
+      '$',
+      '%',
+      '&',
+      '’',
+      '(',
+      ')',
+      '*',
+      '/',
+      ':',
+      ';',
+      '<',
+      '=',
+      '>',
+      '?',
+      '@',
+      '\\',
+      '^',
+      '`',
+      '{',
+      '|',
+      '}',
+      '~',
+    ];
+    foreach ($unsafe_chars as $unsafe_char) {
+      $data['unsafe-' . $unsafe_char] = ['kitt' . $unsafe_char . 'ens', FALSE];
+    }
+
+    for ($ascii = 0; $ascii <= 0x1F; $ascii++) {
+      $data['unsafe-' . $ascii] = ['kitt' . chr($ascii) . 'ens', FALSE];
+    }
+
+    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..b0f465d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php
@@ -0,0 +1,155 @@
+<?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\Routing\Param\Filter;
+use Drupal\jsonapi\Routing\Param\Sort;
+use Drupal\jsonapi\Routing\Param\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
+ */
+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 mock for the entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * A request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * @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([], 'node', $this->fieldManager),
+        'sort' => new Sort([]),
+        'page' => new OffsetPage([]),
+        // '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);
+
+    $expected = new Sort([]);
+    $actual = $request_context->getJsonApiParameter('sort');
+
+    $this->assertEquals($expected, $actual);
+  }
+
+  /**
+   * @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/Context/FieldResolverTest.php b/core/modules/jsonapi/tests/src/Unit/Context/FieldResolverTest.php
new file mode 100644
index 0000000..0fe7e6c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Context/FieldResolverTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Context;
+
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\Tests\UnitTestCase;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Context\CurrentContext;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Context\FieldResolver
+ * @group jsonapi
+ */
+class FieldResolverTest extends UnitTestCase {
+
+  /**
+   * A mock for the current context service.
+   *
+   * @var \Drupal\jsonapi\Context\CurrentContext
+   */
+  protected $currentContext;
+
+  /**
+   * A mock for the entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    $current_context = $this->prophesize(CurrentContext::class);
+
+    $current_context->getResourceType()
+      ->willReturn(new ResourceType('lorem', $this->randomMachineName(), NULL));
+
+    $this->currentContext = $current_context->reveal();
+  }
+
+  /**
+   * Expects a public field name to be expanded into a Drupal field name.
+   *
+   * @covers ::resolveInternal
+   */
+  public function testResolveInternalNested() {
+    $field_manager = $this->prophesize(EntityFieldManagerInterface::class);
+    $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage1->getSetting('target_type')->willReturn('ipsum');
+    $field_storage2 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage2->getSetting('target_type')->willReturn('dolor');
+    $field_storage3 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage3->getSetting('target_type')->willReturn(NULL);
+    $field_manager->getFieldStorageDefinitions('lorem')
+      ->willReturn(['host' => $field_storage1->reveal()]);
+    $field_manager->getFieldStorageDefinitions('ipsum')
+      ->willReturn(['nested' => $field_storage2->reveal()]);
+    $field_manager->getFieldStorageDefinitions('dolor')
+      ->willReturn(['deep' => $field_storage3->reveal()]);
+
+    $original = 'host.nested.deep';
+    $expected = 'host.entity.nested.entity.deep';
+    $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal());
+
+    $this->assertEquals($expected, $field_resolver->resolveInternal($original));
+  }
+
+  /**
+   * Expects a public field name to be expanded into a Drupal field name ending
+   * with a complex field.
+   *
+   * @covers ::resolveInternal
+   */
+  public function testResolveInternalComplex() {
+    $field_manager = $this->prophesize(EntityFieldManagerInterface::class);
+    $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage1->getSetting('target_type')->willReturn('ipsum');
+    $field_storage2 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage2->getSetting('target_type')->willReturn(NULL);
+    $field_manager->getFieldStorageDefinitions('lorem')
+      ->willReturn(['host' => $field_storage1->reveal()]);
+    $field_manager->getFieldStorageDefinitions('ipsum')
+      ->willReturn(['nested' => $field_storage2->reveal()]);
+
+    $original = 'host.nested.deep';
+    $expected = 'host.entity.nested.deep';
+    $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal());
+
+    $this->assertEquals($expected, $field_resolver->resolveInternal($original));
+  }
+
+  /**
+   * Expects an error when an invalid field is provided.
+   *
+   * @covers ::resolveInternal
+   *
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   */
+  public function testResolveInternalError() {
+    $field_manager = $this->prophesize(EntityFieldManagerInterface::class);
+    $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class);
+    $field_storage1->getType()->willReturn('entity_reference');
+    $field_storage1->getSetting('target_type')->willReturn('ipsum');
+    $field_manager->getFieldStorageDefinitions('lorem')
+      ->willReturn(['fail' => $field_storage1->reveal()]);
+
+    $original = 'host.nested.deep';
+    $not_expected = 'host.entity.nested.entity.deep';
+    $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal());
+
+    $this->assertEquals($not_expected, $field_resolver->resolveInternal($original));
+  }
+
+}
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..8f86e18
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Controller;
+
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Context\CurrentContext;
+use Drupal\jsonapi\Controller\RequestHandler;
+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
+ */
+class RequestHandlerTest extends UnitTestCase {
+
+  /**
+   * @covers ::deserializeBody
+   * @expectedException \Symfony\Component\HttpKernel\Exception\HttpException
+   * @expectedExceptionMessageRegExp "There was an error un-serializing the data\..*"
+   */
+  public function testDeserializeBodyFail() {
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+    $request_handler = new RequestHandler($entity_storage->reveal());
+    $request = $this->prophesize(Request::class);
+    $request->getContentType()->willReturn(NULL);
+    $request->getContent()->willReturn('this is not used');
+    $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));
+    try {
+      $request_handler->deserializeBody(
+        $request->reveal(),
+        $serializer->reveal(),
+        'invalid',
+        $current_context->reveal()
+      );
+      $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/LinkManager/LinkManagerTest.php b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
new file mode 100644
index 0000000..996e59c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\LinkManager;
+use Drupal\Core\Routing\UrlGeneratorInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Cmf\Component\Routing\ChainRouterInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\LinkManager\LinkManager
+ * @group jsonapi
+ */
+class LinkManagerTest extends UnitTestCase {
+
+  /**
+   * The SUT.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $router = $this->prophesize(ChainRouterInterface::class);
+    $router->matchRequest(Argument::type(Request::class))->willReturn([
+      RouteObjectInterface::ROUTE_NAME => 'fake',
+      '_raw_variables' => new ParameterBag(['lorem' => 'ipsum']),
+    ]);
+    $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, array $pages) {
+    // Add the extra stuff to the expected query.
+    $pages = array_filter($pages);
+    $pages = array_map(function ($page) {
+      return ['absolute' => TRUE, 'query' => ['page' => $page]];
+    }, $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->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]);
+    $request->query = new ParameterBag();
+
+    $links = $this->linkManager
+      ->getPagerLinks($request->reveal(), ['has_next_page' => $has_next_page]);
+    $this->assertEquals($pages, $links);
+  }
+
+  /**
+   * Data provider for testGetPagerLinks
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function getPagerLinksProvider() {
+    return [
+      [1, 4, TRUE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 0, 'limit' => 4],
+        'next' => ['offset' => 5, 'limit' => 4],
+      ]],
+      [6, 4, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 2, 'limit' => 4],
+        'next' => NULL,
+      ]],
+      [7, 4, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 3, 'limit' => 4],
+        'next' => NULL,
+      ]],
+      [10, 4, FALSE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 6, 'limit' => 4],
+        'next' => NULL,
+      ]],
+      [5, 4, TRUE, [
+        'first' => ['offset' => 0, 'limit' => 4],
+        'prev' => ['offset' => 1, 'limit' => 4],
+        'next' => ['offset' => 9, 'limit' => 4],
+      ]],
+      [0, 4, TRUE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => ['offset' => 4, 'limit' => 4],
+      ]],
+      [0, 1, FALSE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => NULL,
+      ]],
+      [0, 1, FALSE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'next' => NULL,
+      ]],
+    ];
+  }
+
+  /**
+   * Test errors.
+   *
+   * @covers ::getPagerLinks
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   * @dataProvider getPagerLinksErrorProvider
+   */
+  public function testGetPagerLinksError($offset, $size, $total, array $pages) {
+    $this->testGetPagerLinks($offset, $size, $total, $pages);
+  }
+
+  /**
+   * Data provider for testGetPagerLinksError.
+   *
+   * @return array
+   *   The data for the test method.
+   */
+  public function getPagerLinksErrorProvider() {
+    return [
+      [0, -5, FALSE, [
+        'first' => NULL,
+        'prev' => NULL,
+        'last' => NULL,
+        'next' => NULL,
+      ]],
+    ];
+  }
+
+  /**
+   * @covers ::getRequestLink
+   */
+  public function testGetRequestLink() {
+    $request = $this->prophesize(Request::class);
+    // Have the request return the desired page parameter.
+    $page_param = $this->prophesize(OffsetPage::class);
+    $page_param->getOffset()->willReturn(NULL);
+    $page_param->getSize()->willReturn(NULL);
+    $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]);
+    $request->query = new ParameterBag(['amet' => 'pax']);
+
+    $query = $this->linkManager->getRequestLink($request->reveal(), ['dolor' => 'sid']);
+    $this->assertEquals([
+      'absolute' => TRUE,
+      'query' => ['dolor' => 'sid'],
+    ], $query);
+    // Get the default query from the request object.
+    $query = $this->linkManager->getRequestLink($request->reveal());
+    $this->assertEquals([
+      'absolute' => TRUE,
+      'query' => ['amet' => 'pax'],
+    ], $query);
+  }
+
+}
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..732dca0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
@@ -0,0 +1,89 @@
+<?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\jsonapi\Normalizer\ScalarNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\Serializer\Serializer;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
+ * @group jsonapi
+ */
+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()
+    );
+
+    $normalizers = [new ScalarNormalizer()];
+    $serializer = new Serializer($normalizers, []);
+    $this->normalizer->setSerializer($serializer);
+  }
+
+  /**
+   * @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'], 'dolor'],
+      [
+        ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]],
+        ['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..2f27c5d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php
@@ -0,0 +1,159 @@
+<?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;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
+ * @group jsonapi
+ */
+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
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   * @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->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..f72db5c
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
+ * @group jsonapi
+ */
+class HttpExceptionNormalizerTest extends UnitTestCase {
+
+  /**
+   * @covers ::normalize
+   */
+  public function testNormalize() {
+    $exception = new AccessDeniedHttpException('lorem', NULL, 13);
+    $current_user = $this->prophesize(AccountProxyInterface::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(AccountProxyInterface::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..6d7ca9d
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+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;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
+ * @group jsonapi
+ */
+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);
+
+    $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());
+
+    $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()
+    );
+
+    $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'],
+      ],
+      [
+        [
+          'data' => [
+            'type' => 'lorem',
+            'id' => '0676d1bf-55b3-4bbc-9fbc-3df10f4599d5',
+            'relationships' => ['field_dummy' => ['data' => ['type' => 'node', 'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836']]],
+          ],
+        ],
+        ['field_dummy' => [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']]]],
+          ],
+        ],
+        ['field_dummy' => [1, 2]],
+      ],
+    ];
+  }
+
+}
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..7c78146
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+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
+ */
+class EntityNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The EntityNormalizerValue object.
+   *
+   * @var \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
+   */
+  protected $object;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $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' => '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');
+    $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 ::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..60bb2a4
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue
+ * @group jsonapi
+ */
+class FieldItemNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $expected) {
+    $object = new FieldItemNormalizerValue($values);
+    $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' => new FieldItemNormalizerValue([
+              'dolor' => 'sid',
+              'amet' => new FieldItemNormalizerValue(['value' => '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..2c94b20
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\jsonapi\Normalizer\Value\FieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue
+ * @group jsonapi
+ */
+class FieldNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $cardinality, $expected) {
+    $object = new FieldNormalizerValue($values, $cardinality);
+    $this->assertEquals($expected, $object->rasterizeValue());
+  }
+
+  /**
+   * 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->getInclude()->willReturn(NULL);
+    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(FieldItemNormalizerValue::class);
+    $include = $this->prophesize('\Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue');
+    $include->rasterizeValue()->willReturn('Lorem');
+    $value->getInclude()->willReturn($include->reveal());
+    $object = new FieldNormalizerValue([$value->reveal()], 1);
+    $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..6731135
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+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
+ */
+class JsonApiDocumentTopLevelNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * The JsonApiDocumentTopLevelNormalizerValue object.
+   *
+   * @var JsonApiDocumentTopLevelNormalizerValue
+   */
+  protected $object;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $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,
+        $entity->reveal(),
+        ['link_manager' => $link_manager->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..f9f16a7
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\RelationshipItemNormalizerValue
+ * @group jsonapi
+ */
+class RelationshipItemNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $entity_type_id, $bundle, $expected) {
+    $object = new RelationshipItemNormalizerValue($values, new ResourceType($entity_type_id, $bundle, NULL));
+    $this->assertEquals($expected, $object->rasterizeValue());
+  }
+
+  /**
+   * Data provider for testRasterizeValue.
+   */
+  public function rasterizeValueProvider() {
+    return [
+      [['target_id' => 1], 'node', 'article', ['type' => 'node--article', 'id' => 1]],
+      [['value' => 1], 'node', 'page', ['type' => 'node--page', 'id' => 1]],
+      [[1], 'node', 'foo', ['type' => 'node--foo', 'id' => 1]],
+      [[], 'node', 'bar', []],
+      [[NULL], 'node', 'baz', NULL],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php
new file mode 100644
index 0000000..92184cb
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
+
+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
+ */
+class RelationshipNormalizerValueTest extends UnitTestCase {
+
+  /**
+   * @covers ::rasterizeValue
+   * @dataProvider rasterizeValueProvider
+   */
+  public function testRasterizeValue($values, $cardinality, $expected) {
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $object = new RelationshipNormalizerValue($values, $cardinality, [
+      'link_manager' => $link_manager->reveal(),
+      'host_entity_id' => 'lorem',
+      'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL),
+      'field_name' => 'ipsum',
+    ]);
+    $this->assertEquals($expected, $object->rasterizeValue());
+  }
+
+  /**
+   * 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);
+    $uid2 = $this->prophesize(RelationshipItemNormalizerValue::class);
+    $uid2->rasterizeValue()->willReturn(['type' => 'user', 'id' => $uid_raw]);
+    $uid2->getInclude()->willReturn(NULL);
+    $links = [
+      'self' => 'dummy_entity_link',
+      'related' => 'dummy_entity_link',
+    ];
+    return [
+      [[$uid1->reveal()], 1, [
+        'data' => ['type' => 'user', 'id' => 1],
+        'links' => $links,
+      ]],
+      [
+        [$uid1->reveal(), $uid2->reveal()], 2, [
+          'data' => [
+            ['type' => 'user', 'id' => 1],
+            ['type' => 'user', 'id' => 2],
+          ],
+          'links' => $links,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::rasterizeValue
+   *
+   * @expectedException \RuntimeException
+   */
+  public function testRasterizeValueFails() {
+    $uid1 = $this->prophesize(FieldItemNormalizerValue::class);
+    $uid1->rasterizeValue()->willReturn(1);
+    $uid1->getInclude()->willReturn(NULL);
+    $link_manager = $this->prophesize(LinkManager::class);
+    $link_manager
+      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
+      ->willReturn('dummy_entity_link');
+    $object = new RelationshipNormalizerValue([$uid1->reveal()], 1, [
+      'link_manager' => $link_manager->reveal(),
+      'host_entity_id' => 'lorem',
+      'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL),
+      'field_name' => 'ipsum',
+    ]);
+    $object->rasterizeValue();
+    // If the exception was not thrown, then the following fails.
+    $this->assertTrue(FALSE);
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php b/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php
new file mode 100644
index 0000000..f5a1daf
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit;
+
+use Drupal\jsonapi\RequestCacheabilityDependency;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\RequestCacheabilityDependency
+ * @group jsonapi
+ */
+class RequestCacheabilityDependencyTest extends UnitTestCase {
+
+  /**
+   * Cacheable dependency under test.
+   *
+   * @var \Drupal\Core\Cache\CacheableDependencyInterface
+   */
+  protected $cacheableDependency;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->cacheableDependency = new RequestCacheabilityDependency();
+  }
+
+
+  /**
+   * @covers ::getCacheContexts
+   */
+  public function testGetCacheContexts() {
+    $this->assertArrayEquals([
+      'url.query_args:filter',
+      'url.query_args:sort',
+      'url.query_args:page',
+      'url.query_args:fields',
+      'url.query_args:include',
+    ], $this->cacheableDependency->getCacheContexts());
+  }
+
+  /**
+   * @covers ::getCacheContexts
+   */
+  public function testGetCacheTags() {
+    $this->assertArrayEquals([], $this->cacheableDependency->getCacheTags());
+  }
+
+  /**
+   * @covers ::getCacheContexts
+   */
+  public function testGetCacheMaxAge() {
+    $this->assertEquals(-1, $this->cacheableDependency->getCacheMaxAge());
+  }
+
+}
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..ba99ae6
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\jsonapi\Routing\JsonApiParamEnhancer;
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Routing\Param\Filter;
+use Drupal\jsonapi\Routing\Param\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;
+
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\JsonApiParamEnhancer
+ * @group jsonapi
+ * @group jsonapi_param_enhancer
+ */
+class JsonApiParamEnhancerTest extends UnitTestCase {
+
+  /**
+   * @covers ::applies
+   */
+  public function testApplies() {
+    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    $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() {
+    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    $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');
+    $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() {
+    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    $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();
+
+    $defaults = $object->enhance([], $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() {
+    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    $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();
+
+    $defaults = $object->enhance([], $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']));
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php
new file mode 100644
index 0000000..15b1928
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\jsonapi\Routing\Param\Filter;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\Param\Filter
+ * @group jsonapi
+ * @group jsonapi_params
+ */
+class FilterTest extends UnitTestCase {
+
+  /**
+   * @covers ::get
+   * @dataProvider getProvider
+   */
+  public function testGet($original, $expected) {
+    $pager = new Filter(
+      $original,
+      'lorem',
+      $this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    $this->assertEquals($expected, $pager->get());
+  }
+
+  /**
+   * Data provider for testGet.
+   */
+  public function getProvider() {
+    return [
+      [ // Tests filter[0][field]=foo&filter[0][value]=bar
+        [['path' => 'foo', 'value' => 'bar']],
+        [['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
+      ],
+      [ // Tests filter[foo][value]=bar
+        ['foo' => ['value' => 'bar']],
+        ['foo' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
+      ],
+      [ // Tests filter[foo][value]=bar&filter[foo][operator]=>
+        ['foo' => ['value' => 'bar', 'operator' => '>']],
+        ['foo' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '>']]],
+      ],
+      [ // Tests filter[foo][value][]=1&filter[foo][value][]=2&filter[foo][value][]=3&filter[foo][operator]=NOT IN
+        ['foo' => ['value' => ['1', '2', '3'], 'operator' => 'NOT IN']],
+        ['foo' => ['condition' => [ 'path' => 'foo', 'value' => ['1', '2', '3'], 'operator' => 'NOT IN']]],
+      ],
+      [ // Tests filter[foo][value][]=1&filter[foo][value][]=10&filter[foo][operator]=BETWEEN
+        ['foo' => ['value' => ['1', '10'], 'operator' => 'BETWEEN']],
+        ['foo' => ['condition' => [ 'path' => 'foo', 'value' => ['1', '10'], 'operator' => 'BETWEEN']]],
+      ],
+      [ // Tests filter[0][field]=foo&filter[0][value]=1&filter[0][operator]=>
+        [['path' => 'foo', 'value' => '1', 'operator' => '>']],
+        [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]],
+      ],
+      [ // Tests filter[0][condition][field]=foo&filter[0][condition][value]=1&filter[0][condition][operator]=>
+        [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]],
+        [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]],
+      ],
+      [ // Tests filter[0][field]=foo&filter[0][value][]=bar&filter[0][value][]=baz
+        [['path' => 'foo', 'value' => ['bar', 'baz']]],
+        [['condition' => [ 'path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=']]],
+      ],
+      [
+        [ // Tests filter[0][field]=foo&filter[0][value]=bar&filter[1][condition][field]=baz&filter[1][condition][value]=zab&filter[1][condition][operator]=<>
+          0 => ['path' => 'foo', 'value' => 'bar'],
+          1 => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
+        ],
+        [
+          0 => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']],
+          1 => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
+        ],
+      ],
+      [
+        [ // Tests filter[zero][field]=foo&filter[zero][value]=bar&filter[one][condition][field]=baz&filter[one][condition][value]=zab&filter[one][condition][operator]=<>
+          'zero' => ['path' => 'foo', 'value' => 'bar'],
+          'one' => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
+        ],
+        [
+          'zero' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']],
+          'one' => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::get
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   */
+  public function testGetFail() {
+    $pager = new Filter(
+      'lorem',
+      'ipsum',
+      $this->prophesize(EntityFieldManagerInterface::class)->reveal()
+    );
+    $pager->get();
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php
new file mode 100644
index 0000000..c23e5e0
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
+
+use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\Param\OffsetPage
+ * @group jsonapi
+ */
+class OffsetPageTest extends UnitTestCase {
+
+  /**
+   * @covers ::get
+   * @dataProvider getProvider
+   */
+  public function testGet($original, $max_page, $expected) {
+    $pager = new OffsetPage($original, $max_page);
+    $this->assertEquals($expected, $pager->get());
+  }
+
+  /**
+   * Data provider for testGet.
+   */
+  public function getProvider() {
+    return [
+      [['offset' => 12, 'limit' => 20], 50, ['offset' => 12, 'limit' => 20]],
+      [['offset' => 12, 'limit' => 60], 50, ['offset' => 12, 'limit' => 50]],
+      [['offset' => 12], 50, ['offset' => 12, 'limit' => 50]],
+      [['offset' => 0], 50, ['offset' => 0, 'limit' => 50]],
+      [[], 50, ['limit' => 50]],
+    ];
+  }
+
+  /**
+   * @covers ::get
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   */
+  public function testGetFail() {
+    $pager = new OffsetPage('lorem');
+    $pager->get();
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php
new file mode 100644
index 0000000..a407ea5
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
+
+use Drupal\jsonapi\Routing\Param\Sort;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Routing\Param\Sort
+ * @group jsonapi
+ */
+class SortTest extends UnitTestCase {
+
+  /**
+   * @covers ::get
+   * @dataProvider getProvider
+   */
+  public function testGet($original, $expected) {
+    $sort = new Sort($original);
+    $this->assertEquals($expected, $sort->get());
+  }
+
+  /**
+   * Data provider for testGet.
+   */
+  public function getProvider() {
+    return [
+      ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
+      ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]],
+      ['-lorem,ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL]
+      ]],
+      ['-lorem,-ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL]
+      ]],
+      [[
+        ['path' => 'lorem', 'langcode' => NULL],
+        ['path' => 'ipsum', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ], [
+        ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ]],
+    ];
+  }
+
+  /**
+   * @covers ::get
+   * @dataProvider getFailProvider
+   * @expectedException \Drupal\jsonapi\Exception\SerializableHttpException
+   */
+  public function testGetFail($input) {
+    $sort = new Sort($input);
+    $sort->get();
+  }
+
+  /**
+   * Data provider for testGetFail.
+   */
+  public function getFailProvider() {
+    return [
+      [[['lorem']]],
+      [''],
+    ];
+  }
+
+}
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
new file mode 100644
index 0000000..c7d4476
--- /dev/null
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
@@ -0,0 +1,129 @@
+<?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
+ */
+class RoutesTest extends UnitTestCase {
+
+  /**
+   * List of routes objects for the different scenarios.
+   *
+   * @var Routes[]
+   */
+  protected $routes;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
+    $resource_type_repository->all()->willReturn([new ResourceType('entity_type_1', 'bundle_1_1', EntityInterface::class)]);
+    $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 each 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'));
+  }
+
+}
