From 7dfe3876f5e76e440ec136e1dec02d2f7110de5e Mon Sep 17 00:00:00 2001
From: Lee Rowlands <lee.rowlands@previousnext.com.au>
Date: Tue, 1 Oct 2019 11:17:01 +1000
Subject: [PATCH] Issue #3014277 by gabesullice, e0ipso, Wim Leers, larowlan,
 jibran, ndobromirov, catch: ResourceTypes should know about their fields

---
 .../jsonapi/src/Context/FieldResolver.php     |  73 ++----
 .../jsonapi/src/Controller/EntityResource.php |  45 ++--
 .../jsonapi/src/Controller/FileUpload.php     |   2 +-
 .../src/JsonApiResource/Relationship.php      |   9 +-
 .../EntityReferenceFieldNormalizer.php        | 118 ++++++++++
 .../src/Normalizer/FieldNormalizer.php        |  14 +-
 .../jsonapi/src/ResourceType/ResourceType.php | 208 ++++++++++++++----
 .../ResourceType/ResourceTypeAttribute.php    |  16 ++
 .../src/ResourceType/ResourceTypeField.php    | 129 +++++++++++
 .../ResourceType/ResourceTypeRelationship.php |  63 ++++++
 .../ResourceType/ResourceTypeRepository.php   | 103 ++++++---
 .../CountableResourceTypeRepository.php       |   3 +-
 .../AliasingResourceTypeRepository.php        |  10 +-
 .../ResourceTypeRepositoryTest.php            |   2 +-
 .../src/Kernel/Serializer/SerializerTest.php  |  14 +-
 .../ResourceIdentifierNormalizerTest.php      |   7 +-
 .../tests/src/Unit/Routing/RoutesTest.php     |  10 +-
 17 files changed, 661 insertions(+), 165 deletions(-)
 create mode 100644 core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
 create mode 100644 core/modules/jsonapi/src/ResourceType/ResourceTypeAttribute.php
 create mode 100644 core/modules/jsonapi/src/ResourceType/ResourceTypeField.php
 create mode 100644 core/modules/jsonapi/src/ResourceType/ResourceTypeRelationship.php

diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php
index ef58a086ddab..7f65f3d8aa26 100644
--- a/core/modules/jsonapi/src/Context/FieldResolver.php
+++ b/core/modules/jsonapi/src/Context/FieldResolver.php
@@ -19,6 +19,7 @@
 use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
 use Drupal\Core\TypedData\DataReferenceTargetDefinition;
 use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
 use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
 
@@ -336,7 +337,7 @@ public function resolveInternalEntityQueryPath($resource_type, $external_field_n
       }
 
       // Get all of the referenceable resource types.
-      $resource_types = $this->getReferenceableResourceTypes($candidate_definitions);
+      $resource_types = $this->getRelatableResourceTypes($resource_types, $candidate_definitions);
 
       $at_least_one_entity_reference_field = FALSE;
       $candidate_property_names = array_unique(NestedArray::mergeDeepArray(array_map(function (FieldItemDataDefinitionInterface $definition) use (&$at_least_one_entity_reference_field) {
@@ -546,54 +547,27 @@ protected function isMemberFilterable($external_name, array $resource_types) {
   /**
    * Get the referenceable ResourceTypes for a set of field definitions.
    *
-   * @param \Drupal\Core\Field\FieldDefinitionInterface[] $definitions
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
    *   The resource types on which the reference field might exist.
+   * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface[] $definitions
+   *   The field item definitions of targeted fields, keyed by the resource
+   *   type name on which they reside.
    *
    * @return \Drupal\jsonapi\ResourceType\ResourceType[]
    *   The referenceable target resource types.
    */
-  protected function getReferenceableResourceTypes(array $definitions) {
-    return array_reduce($definitions, function ($result, $definition) {
-      $resource_types = array_filter(
-        $this->collectResourceTypesForReference($definition)
-      );
-      $type_names = array_map(function ($resource_type) {
-        /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
-        return $resource_type->getTypeName();
-      }, $resource_types);
-      return array_merge($result, array_combine($type_names, $resource_types));
-    }, []);
-  }
-
-  /**
-   * Build a list of resource types depending on which bundles are referenced.
-   *
-   * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface $item_definition
-   *   The reference definition.
-   *
-   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
-   *   The list of resource types.
-   */
-  protected function collectResourceTypesForReference(FieldItemDataDefinitionInterface $item_definition) {
-    $main_property_definition = $item_definition->getPropertyDefinition(
-      $item_definition->getMainPropertyName()
-    );
-
-    // Check if the field is a flavor of an Entity Reference field.
-    if (!$main_property_definition instanceof DataReferenceTargetDefinition) {
-      return [];
+  protected function getRelatableResourceTypes(array $resource_types, array $definitions) {
+    $relatable_resource_types = [];
+    foreach ($resource_types as $resource_type) {
+      $definition = $definitions[$resource_type->getTypeName()];
+      $resource_type_field = $resource_type->getFieldByInternalName($definition->getFieldDefinition()->getName());
+      if ($resource_type_field instanceof ResourceTypeRelationship) {
+        foreach ($resource_type_field->getRelatableResourceTypes() as $relatable_resource_type) {
+          $relatable_resource_types[$relatable_resource_type->getTypeName()] = $relatable_resource_type;
+        }
+      }
     }
-    $entity_type_id = $item_definition->getSetting('target_type');
-    $handler_settings = $item_definition->getSetting('handler_settings');
-
-    $has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']);
-    $target_bundles = $has_target_bundles ?
-      $handler_settings['target_bundles']
-      : $this->getAllBundlesForEntityType($entity_type_id);
-
-    return array_map(function ($bundle) use ($entity_type_id) {
-      return $this->resourceTypeRepository->get($entity_type_id, $bundle);
-    }, $target_bundles);
+    return $relatable_resource_types;
   }
 
   /**
@@ -622,19 +596,6 @@ protected function resourceTypesAreTraversable(array $resource_types) {
     return FALSE;
   }
 
-  /**
-   * Gets all bundle IDs for a given entity type.
-   *
-   * @param string $entity_type_id
-   *   The entity type for which to get bundles.
-   *
-   * @return string[]
-   *   The bundle IDs.
-   */
-  protected function getAllBundlesForEntityType($entity_type_id) {
-    return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
-  }
-
   /**
    * Gets all unique reference property names from the given field definitions.
    *
diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php
index b0febdc49b7c..162ce068bf91 100644
--- a/core/modules/jsonapi/src/Controller/EntityResource.php
+++ b/core/modules/jsonapi/src/Controller/EntityResource.php
@@ -47,6 +47,7 @@
 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
 use Drupal\jsonapi\ResourceResponse;
 use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeField;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
 use Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer;
 use Symfony\Component\HttpFoundation\Request;
@@ -236,24 +237,22 @@ public function createIndividual(ResourceType $resource_type, Request $request)
       // by the user. Field access makes no distinction between 'create' and
       // 'update', so the 'edit' operation is used here.
       $document = Json::decode($request->getContent());
+      $field_mapping = array_map(function (ResourceTypeField $field) {
+        return $field->getPublicName();
+      }, $resource_type->getFields());
+      // User resource objects contain a read-only attribute that is not a
+      // real field on the user entity type.
+      // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
+      // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
+      if ($resource_type->getEntityTypeId() === 'user') {
+        $field_mapping = array_diff($field_mapping, [$resource_type->getPublicName('display_name')]);
+      }
       foreach (['attributes', 'relationships'] as $data_member_name) {
         if (isset($document['data'][$data_member_name])) {
-          $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) {
-            return $resource_type->getInternalName($public_field_name);
-          }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) {
-            return $resource_type->hasField($internal_field_name);
-          });
-          // User resource objects contain a read-only attribute that is not a
-          // real field on the user entity type.
-          // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
-          // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
-          if ($resource_type->getEntityTypeId() === 'user') {
-            $valid_names = array_diff($valid_names, [$resource_type->getPublicName('display_name')]);
-          }
-          foreach ($valid_names as $field_name) {
-            $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE);
+          foreach (array_intersect_key(array_flip($field_mapping), $document['data'][$data_member_name]) as $internal_field_name) {
+            $field_access = $parsed_entity->get($internal_field_name)->access('edit', NULL, TRUE);
             if (!$field_access->isAllowed()) {
-              $public_field_name = $resource_type->getPublicName($field_name);
+              $public_field_name = $field_mapping[$internal_field_name];
               throw new EntityAccessDeniedHttpException(NULL, $field_access, "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', $public_field_name));
             }
           }
@@ -512,7 +511,8 @@ protected function executeQueryInRenderContext(QueryInterface $query, CacheableM
    */
   public function getRelated(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) {
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
-    $field_list = $entity->get($resource_type->getInternalName($related));
+    $resource_relationship = $resource_type->getFieldByPublicName($related);
+    $field_list = $entity->get($resource_relationship->getInternalName());
 
     // Remove the entities pointing to a resource that may be disabled. Even
     // though the normalizer skips disabled references, we can avoid unnecessary
@@ -531,7 +531,7 @@ function (EntityInterface $entity) {
     foreach ($referenced_entities as $referenced_entity) {
       $collection_data[] = $this->entityAccessChecker->getAccessCheckedResourceObject($referenced_entity);
     }
-    $primary_data = new ResourceObjectData($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality());
+    $primary_data = new ResourceObjectData($collection_data, $resource_relationship->hasOne() ? 1 : -1);
     $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data));
 
     // $response does not contain the entity list cache tag. We add the
@@ -598,11 +598,11 @@ public function getRelationship(ResourceType $resource_type, FieldableEntityInte
    */
   public function addToRelationshipData(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) {
     $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
-    $related = $resource_type->getInternalName($related);
+    $internal_relationship_field_name = $resource_type->getInternalName($related);
     // According to the specification, you are only allowed to POST to a
     // relationship if it is a to-many relationship.
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
-    $field_list = $entity->{$related};
+    $field_list = $entity->{$internal_relationship_field_name};
     /* @var \Drupal\field\Entity\FieldConfig $field_definition */
     $field_definition = $field_list->getFieldDefinition();
     $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
@@ -662,12 +662,12 @@ public function addToRelationshipData(ResourceType $resource_type, FieldableEnti
    */
   public function replaceRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) {
     $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
-    $related = $resource_type->getInternalName($related);
+    $internal_relationship_field_name = $resource_type->getInternalName($related);
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $resource_identifiers */
     // According to the specification, PATCH works a little bit different if the
     // relationship is to-one or to-many.
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
-    $field_list = $entity->{$related};
+    $field_list = $entity->{$internal_relationship_field_name};
     $field_definition = $field_list->getFieldDefinition();
     $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
     $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship';
@@ -745,8 +745,9 @@ protected function doPatchMultipleRelationship(EntityInterface $entity, array $r
    */
   public function removeFromRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) {
     $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related);
+    $internal_relationship_field_name = $resource_type->getInternalName($related);
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
-    $field_list = $entity->{$related};
+    $field_list = $entity->{$internal_relationship_field_name};
     $is_multiple = $field_list->getFieldDefinition()
       ->getFieldStorageDefinition()
       ->isMultiple();
diff --git a/core/modules/jsonapi/src/Controller/FileUpload.php b/core/modules/jsonapi/src/Controller/FileUpload.php
index b07dd51d0de4..27032eaf3a8a 100644
--- a/core/modules/jsonapi/src/Controller/FileUpload.php
+++ b/core/modules/jsonapi/src/Controller/FileUpload.php
@@ -128,7 +128,7 @@ public function handleFileUploadForExistingResource(Request $request, ResourceTy
       throw new UnprocessableEntityHttpException($message);
     }
 
-    if ($field_definition->getFieldStorageDefinition()->getCardinality() === 1) {
+    if ($resource_type->getFieldByInternalName($file_field_name)->hasOne()) {
       $entity->{$file_field_name} = $file;
     }
     else {
diff --git a/core/modules/jsonapi/src/JsonApiResource/Relationship.php b/core/modules/jsonapi/src/JsonApiResource/Relationship.php
index 0f6f9a6325ec..17975f810d73 100644
--- a/core/modules/jsonapi/src/JsonApiResource/Relationship.php
+++ b/core/modules/jsonapi/src/JsonApiResource/Relationship.php
@@ -117,11 +117,10 @@ protected function __construct($public_field_name, RelationshipData $data, LinkC
    */
   public static function createFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links = NULL, array $meta = []) {
     $context_resource_type = $context->getResourceType();
-    $public_field_name = $context_resource_type->getPublicName($field->getName());
-    $field_cardinality = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality();
+    $resource_field = $context_resource_type->getFieldByInternalName($field->getName());
     return new static(
-      $public_field_name,
-      new RelationshipData(ResourceIdentifier::toResourceIdentifiers($field), $field_cardinality),
+      $resource_field->getPublicName(),
+      new RelationshipData(ResourceIdentifier::toResourceIdentifiers($field), $resource_field->hasOne() ? 1 : -1),
       static::buildLinkCollectionFromEntityReferenceField($context, $field, $links ?: new LinkCollection([])),
       $meta,
       $context
@@ -228,7 +227,7 @@ public function getMergedMeta(array $top_level_meta) {
    */
   protected static function buildLinkCollectionFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links) {
     $context_resource_type = $context->getResourceType();
-    $public_field_name = $context_resource_type->getPublicName($context_resource_type->getPublicName($field->getName()));
+    $public_field_name = $context_resource_type->getPublicName($field->getName());
     if ($context_resource_type->isLocatable() && !$context_resource_type->isInternal()) {
       $context_is_versionable = $context_resource_type->isVersionable();
       if (!$links->hasLinkWithKey('self')) {
diff --git a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
new file mode 100644
index 000000000000..c79d56ae1ae8
--- /dev/null
+++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\JsonApiSpec;
+use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
+use Drupal\jsonapi\Routing\Routes;
+
+/**
+ * Normalizer class specific for entity reference field objects.
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ */
+class EntityReferenceFieldNormalizer extends FieldNormalizer {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($field, $format = NULL, array $context = []) {
+    assert($field instanceof EntityReferenceFieldItemListInterface);
+    // Build the relationship object based on the Entity Reference and normalize
+    // that object instead.
+    $resource_identifiers = array_filter(ResourceIdentifier::toResourceIdentifiers($field->filterEmptyItems()), function (ResourceIdentifierInterface $resource_identifier) {
+      return !$resource_identifier->getResourceType()->isInternal();
+    });
+    $normalized_items = CacheableNormalization::aggregate($this->serializer->normalize($resource_identifiers, $format, $context));
+    assert($context['resource_object'] instanceof ResourceObject);
+    $resource_relationship = $context['resource_object']->getResourceType()->getFieldByInternalName($field->getName());
+    assert($resource_relationship instanceof ResourceTypeRelationship);
+    $link_cacheability = new CacheableMetadata();
+    $links = array_map(function (Url $link) use ($link_cacheability) {
+      $href = $link->setAbsolute()->toString(TRUE);
+      $link_cacheability->addCacheableDependency($href);
+      return ['href' => $href->getGeneratedUrl()];
+    }, static::getRelationshipLinks($context['resource_object'], $resource_relationship));
+    $data_normalization = $normalized_items->getNormalization();
+    $normalization = [
+      // Empty 'to-one' relationships must be NULL.
+      // Empty 'to-many' relationships must be an empty array.
+      // @link http://jsonapi.org/format/#document-resource-object-linkage
+      'data' => $resource_relationship->hasOne() ? array_shift($data_normalization) : $data_normalization,
+    ];
+    if (!empty($links)) {
+      $normalization['links'] = $links;
+    }
+    return (new CacheableNormalization($normalized_items, $normalization))->withCacheableDependency($link_cacheability);
+  }
+
+  /**
+   * Gets the links for the relationship.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $relationship_context
+   *   The JSON:API resource object context of the relationship.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRelationship $resource_relationship
+   *   The resource type relationship field.
+   *
+   * @return array
+   *   The relationship's links.
+   */
+  public static function getRelationshipLinks(ResourceObject $relationship_context, ResourceTypeRelationship $resource_relationship) {
+    $resource_type = $relationship_context->getResourceType();
+    if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
+      return [];
+    }
+    $public_field_name = $resource_relationship->getPublicName();
+    $relationship_route_name = Routes::getRouteName($resource_type, "$public_field_name.relationship.get");
+    $links = [
+      'self' => Url::fromRoute($relationship_route_name, ['entity' => $relationship_context->getId()]),
+    ];
+    if (static::hasNonInternalResourceType($resource_type->getRelatableResourceTypesByField($public_field_name))) {
+      $related_route_name = Routes::getRouteName($resource_type, "$public_field_name.related");
+      $links['related'] = Url::fromRoute($related_route_name, ['entity' => $relationship_context->getId()]);
+    }
+    if ($resource_type->isVersionable()) {
+      $version_query_parameter = [JsonApiSpec::VERSION_QUERY_PARAMETER => $relationship_context->getVersionIdentifier()];
+      $links['self']->setOption('query', $version_query_parameter);
+      if (isset($links['related'])) {
+        $links['related']->setOption('query', $version_query_parameter);
+      }
+    }
+    return $links;
+  }
+
+  /**
+   * Determines if a given list of resource types contains a non-internal type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
+   *   The JSON:API resource types to evaluate.
+   *
+   * @return bool
+   *   FALSE if every resource type is internal, TRUE otherwise.
+   */
+  protected static function hasNonInternalResourceType(array $resource_types) {
+    foreach ($resource_types as $resource_type) {
+      if (!$resource_type->isInternal()) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
index 3bd46a601bb9..b33677c17867 100644
--- a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
+++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php
@@ -4,7 +4,9 @@
 
 use Drupal\Core\Field\FieldDefinitionInterface;
 use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
+use Drupal\jsonapi\ResourceType\ResourceType;
 use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 
 /**
@@ -31,12 +33,8 @@ class FieldNormalizer extends NormalizerBase implements DenormalizerInterface {
   public function normalize($field, $format = NULL, array $context = []) {
     /* @var \Drupal\Core\Field\FieldItemListInterface $field */
     $normalized_items = $this->normalizeFieldItems($field, $format, $context);
-
-    $cardinality = $field->getFieldDefinition()
-      ->getFieldStorageDefinition()
-      ->getCardinality();
-
-    return $cardinality === 1
+    assert($context['resource_object'] instanceof ResourceObject);
+    return $context['resource_object']->getResourceType()->getFieldByInternalName($field->getName())->hasOne()
       ? array_shift($normalized_items) ?: CacheableNormalization::permanent(NULL)
       : CacheableNormalization::aggregate($normalized_items);
   }
@@ -47,6 +45,8 @@ public function normalize($field, $format = NULL, array $context = []) {
   public function denormalize($data, $class, $format = NULL, array $context = []) {
     $field_definition = $context['field_definition'];
     assert($field_definition instanceof FieldDefinitionInterface);
+    $resource_type = $context['resource_type'];
+    assert($resource_type instanceof ResourceType);
 
     // If $data contains items (recognizable by numerical array keys, which
     // Drupal's Field API calls "deltas"), then it already is itemized; it's not
@@ -61,7 +61,7 @@ public function denormalize($data, $class, $format = NULL, array $context = [])
 
     // Single-cardinality fields don't need itemization.
     $field_item_class = $field_definition->getItemDefinition()->getClass();
-    if (count($itemized_data) === 1 && $field_definition->getFieldStorageDefinition()->getCardinality() === 1) {
+    if (count($itemized_data) === 1 && $resource_type->getFieldByInternalName($field_definition->getName())->hasOne()) {
       return $this->serializer->denormalize($itemized_data[0], $field_item_class, $format, $context);
     }
 
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceType.php b/core/modules/jsonapi/src/ResourceType/ResourceType.php
index 24a883547ccc..15aea30aafb1 100644
--- a/core/modules/jsonapi/src/ResourceType/ResourceType.php
+++ b/core/modules/jsonapi/src/ResourceType/ResourceType.php
@@ -2,6 +2,10 @@
 
 namespace Drupal\jsonapi\ResourceType;
 
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\TypedData\DataReferenceTargetDefinition;
+
 /**
  * Value object containing all metadata for a JSON:API resource type.
  *
@@ -82,28 +86,19 @@ class ResourceType {
   protected $fields;
 
   /**
-   * The list of disabled fields. Disabled by default: uuid, id, type.
-   *
-   * @var string[]
+   * An array of arrays of relatable resource types, keyed by public field name.
    *
-   * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+   * @var array
    */
-  protected $disabledFields;
+  protected $relatableResourceTypesByField;
 
   /**
-   * The mapping for field aliases: keys=internal names, values=public names.
+   * The mapping for field aliases: keys=public names, values=internal names.
    *
    * @var string[]
    */
   protected $fieldMapping;
 
-  /**
-   * The inverse of $fieldMapping.
-   *
-   * @var string[]
-   */
-  protected $invertedFieldMapping;
-
   /**
    * Gets the entity type ID.
    *
@@ -160,8 +155,8 @@ public function getDeserializationTargetClass() {
    */
   public function getPublicName($field_name) {
     // By default the entity field name is the public field name.
-    return isset($this->fieldMapping[$field_name])
-      ? $this->fieldMapping[$field_name]
+    return isset($this->fields[$field_name])
+      ? $this->fields[$field_name]->getPublicName()
       : $field_name;
   }
 
@@ -176,9 +171,47 @@ public function getPublicName($field_name) {
    */
   public function getInternalName($field_name) {
     // By default the entity field name is the public field name.
-    return isset($this->invertedFieldMapping[$field_name])
-      ? $this->invertedFieldMapping[$field_name]
-      : $field_name;
+    return $this->fieldMapping[$field_name] ?? $field_name;
+  }
+
+  /**
+   * Gets the attribute and relationship fields of this resource type.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
+   *   The field objects on this resource type.
+   */
+  public function getFields() {
+    return $this->fields;
+  }
+
+  /**
+   * Gets a particular attribute or relationship field by public field name.
+   *
+   * @param string $public_field_name
+   *   The public field name of the desired field.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceTypeField|null
+   *   A resource type field object or NULL if the field does not exist on this
+   *   resource type.
+   */
+  public function getFieldByPublicName($public_field_name) {
+    return isset($this->fieldMapping[$public_field_name])
+      ? $this->getFieldByInternalName($this->fieldMapping[$public_field_name])
+      : NULL;
+  }
+
+  /**
+   * Gets a particular attribute or relationship field by internal field name.
+   *
+   * @param string $internal_field_name
+   *   The internal field name of the desired field.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceTypeField|null
+   *   A resource type field object or NULL if the field does not exist on this
+   *   resource type.
+   */
+  public function getFieldByInternalName($internal_field_name) {
+    return $this->fields[$internal_field_name] ?? NULL;
   }
 
   /**
@@ -199,7 +232,7 @@ public function getInternalName($field_name) {
    *   otherwise.
    */
   public function hasField($field_name) {
-    return in_array($field_name, $this->fields, TRUE);
+    return array_key_exists($field_name, $this->fields);
   }
 
   /**
@@ -216,7 +249,7 @@ public function hasField($field_name) {
    *   of the data model. FALSE otherwise.
    */
   public function isFieldEnabled($field_name) {
-    return $this->hasField($field_name) && !in_array($field_name, $this->disabledFields, TRUE);
+    return $this->hasField($field_name) && $this->fields[$field_name]->isFieldEnabled();
   }
 
   /**
@@ -307,10 +340,13 @@ public function isVersionable() {
    *   (optional) Whether the resource type is mutable.
    * @param bool $is_versionable
    *   (optional) Whether the resource type is versionable.
-   * @param array $field_mapping
-   *   (optional) The field mapping to use.
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeField[] $fields
+   *   (optional) The resource type fields, keyed by internal field name.
    */
-  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $field_mapping = []) {
+  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $fields = []) {
+    if (!empty($fields) && !reset($fields) instanceof ResourceTypeField) {
+      $fields = $this->updateDeprecatedFieldMapping($fields, $entity_type_id, $bundle);
+    }
     $this->entityTypeId = $entity_type_id;
     $this->bundle = $bundle;
     $this->deserializationTargetClass = $deserialization_target_class;
@@ -318,17 +354,15 @@ public function __construct($entity_type_id, $bundle, $deserialization_target_cl
     $this->isLocatable = $is_locatable;
     $this->isMutable = $is_mutable;
     $this->isVersionable = $is_versionable;
+    $this->fields = $fields;
 
     $this->typeName = $this->bundle === '?'
       ? 'unknown'
       : sprintf('%s--%s', $this->entityTypeId, $this->bundle);
 
-    $this->fields = array_keys($field_mapping);
-    $this->disabledFields = array_keys(array_filter($field_mapping, function ($v) {
-      return $v === FALSE;
-    }));
-    $this->fieldMapping = array_filter($field_mapping, 'is_string');
-    $this->invertedFieldMapping = array_flip($this->fieldMapping);
+    $this->fieldMapping = array_flip(array_map(function (ResourceTypeField $field) {
+      return $field->getPublicName();
+    }, $this->fields));
   }
 
   /**
@@ -341,7 +375,16 @@ public function __construct($entity_type_id, $bundle, $deserialization_target_cl
    *   across resource types across fields, but not within a field.
    */
   public function setRelatableResourceTypes(array $relatable_resource_types) {
-    $this->relatableResourceTypes = $relatable_resource_types;
+    $this->fields = array_reduce(array_keys($relatable_resource_types), function ($fields, $public_field_name) use ($relatable_resource_types) {
+      if (!isset($this->fieldMapping[$public_field_name])) {
+        throw new \LogicException('A field must exist for relatable resource types to be set on it.');
+      }
+      $internal_field_name = $this->fieldMapping[$public_field_name];
+      $field = $fields[$internal_field_name];
+      assert($field instanceof ResourceTypeRelationship);
+      $fields[$internal_field_name] = $field->withRelatableResourceTypes($relatable_resource_types[$public_field_name]);
+      return $fields;
+    }, $this->fields);
   }
 
   /**
@@ -353,10 +396,14 @@ public function setRelatableResourceTypes(array $relatable_resource_types) {
    * @see self::setRelatableResourceTypes()
    */
   public function getRelatableResourceTypes() {
-    if (!isset($this->relatableResourceTypes)) {
-      throw new \LogicException("setRelatableResourceTypes() must be called before getting relatable resource types.");
+    if (!isset($this->relatableResourceTypesByField)) {
+      $this->relatableResourceTypesByField = array_reduce(array_map(function (ResourceTypeRelationship $field) {
+        return [$field->getPublicName() => $field->getRelatableResourceTypes()];
+      }, array_filter($this->fields, function (ResourceTypeField $field) {
+        return $field instanceof ResourceTypeRelationship;
+      })), 'array_merge', []);
     }
-    return $this->relatableResourceTypes;
+    return $this->relatableResourceTypesByField;
   }
 
   /**
@@ -371,10 +418,9 @@ public function getRelatableResourceTypes() {
    * @see self::getRelatableResourceTypes()
    */
   public function getRelatableResourceTypesByField($field_name) {
-    $relatable_resource_types = $this->getRelatableResourceTypes();
-    return isset($relatable_resource_types[$field_name]) ?
-      $relatable_resource_types[$field_name] :
-      [];
+    return ($field = $this->getFieldByPublicName($field_name)) && $field instanceof ResourceTypeRelationship
+      ? $field->getRelatableResourceTypes()
+      : [];
   }
 
   /**
@@ -389,4 +435,92 @@ public function getPath() {
     return sprintf('/%s/%s', $this->getEntityTypeId(), $this->getBundle());
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function __get($name) {
+    $class_name = self::class;
+    @trigger_error("Using the ${$name} protected property of a {$class_name} is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. Use {$class_name}::getFields() instead. See https://www.drupal.org/node/3084721.", E_USER_DEPRECATED);
+    if ($name === 'disabledFields') {
+      return array_map(function (ResourceTypeField $field) {
+        return $field->getInternalName();
+      }, array_filter($this->getFields(), function (ResourceTypeField $field) {
+        return !$field->isFieldEnabled();
+      }));
+    }
+    if ($name === 'invertedFieldMapping') {
+      return array_reduce($this->getFields(), function ($inverted_field_mapping, ResourceTypeField $field) {
+        $internal_field_name = $field->getInternalName();
+        $public_field_name = $field->getPublicName();
+        if ($field->isFieldEnabled() && $internal_field_name !== $public_field_name) {
+          $inverted_field_mapping[$public_field_name] = $internal_field_name;
+        }
+        return $inverted_field_mapping;
+      }, []);
+    }
+  }
+
+  /**
+   * Takes a deprecated field mapping and converts it to ResourceTypeFields.
+   *
+   * @param array $field_mapping
+   *   The deprecated field mapping.
+   * @param string $entity_type_id
+   *   The entity type ID of the field mapping.
+   * @param string $bundle
+   *   The bundle ID of the field mapping or the entity type ID if the entity
+   *   type does not have bundles.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
+   *   The updated field mapping objects.
+   *
+   * @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
+   *   self::getFields() instead.
+   *
+   * @see https://www.drupal.org/project/drupal/issues/3014277
+   */
+  private function updateDeprecatedFieldMapping(array $field_mapping, $entity_type_id, $bundle) {
+    $class_name = self::class;
+    @trigger_error("Passing an array with strings or booleans as a field mapping to {$class_name}::__construct() is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. See \Drupal\jsonapi\ResourceTypeRepository::getFields(). See https://www.drupal.org/node/3084746.", E_USER_DEPRECATED);
+
+    // See \Drupal\jsonapi\ResourceType\ResourceTypeRepository::isReferenceFieldDefinition().
+    $is_reference_field_definition = function (FieldDefinitionInterface $field_definition) {
+      static $field_type_is_reference = [];
+
+      if (isset($field_type_is_reference[$field_definition->getType()])) {
+        return $field_type_is_reference[$field_definition->getType()];
+      }
+
+      /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
+      $item_definition = $field_definition->getItemDefinition();
+      $main_property = $item_definition->getMainPropertyName();
+      $property_definition = $item_definition->getPropertyDefinition($main_property);
+
+      return $field_type_is_reference[$field_definition->getType()] = $property_definition instanceof DataReferenceTargetDefinition;
+    };
+
+    /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager */
+    $entity_type_manager = \Drupal::service('entity_type.manager');
+    $is_fieldable = $entity_type_manager->getDefinition($entity_type_manager)->entityClassImplements(FieldableEntityInterface::class);
+    $field_definitions = $is_fieldable
+      ? \Drupal::service('entity_field.manager')->getFieldDefinitions($entity_type_id, $bundle)
+      : [];
+
+    $fields = [];
+    foreach ($field_mapping as $internal_field_name => $public_field_name) {
+      assert(is_bool($public_field_name) || is_string($public_field_name));
+      $field_definition = $is_fieldable && !empty($field_definitions[$internal_field_name])
+        ? $field_definitions[$internal_field_name]
+        : NULL;
+      $is_relationship_field = $field_definition && $is_reference_field_definition($field_definition);
+      $has_one = !$field_definition || $field_definition->getFieldStorageDefinition()->getCardinality() === 1;
+      $alias = is_string($public_field_name) ? $public_field_name : NULL;
+      $fields[$internal_field_name] = $is_relationship_field
+        ? new ResourceTypeRelationship($internal_field_name, $alias, $public_field_name !== FALSE, $has_one)
+        : new ResourceTypeAttribute($internal_field_name, $alias, $public_field_name !== FALSE, $has_one);
+    }
+
+    return $fields;
+  }
+
 }
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeAttribute.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeAttribute.php
new file mode 100644
index 000000000000..19f71c192f69
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeAttribute.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Specialization of a ResourceTypeField to represent a resource type attribute.
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ */
+class ResourceTypeAttribute extends ResourceTypeField {}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeField.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeField.php
new file mode 100644
index 000000000000..76e35dbcc0c8
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeField.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Abstract value object containing all metadata for a JSON:API resource field.
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ */
+abstract class ResourceTypeField {
+
+  /**
+   * The internal field name.
+   *
+   * @var string
+   */
+  protected $internalName;
+
+  /**
+   * The public field name.
+   *
+   * @var string
+   */
+  protected $publicName;
+
+  /**
+   * Whether the field is disabled.
+   *
+   * @var bool
+   */
+  protected $enabled;
+
+  /**
+   * Whether the field can only have one value.
+   *
+   * @var bool
+   */
+  protected $hasOne;
+
+  /**
+   * ResourceTypeField constructor.
+   *
+   * @param string $internal_name
+   *   The internal field name.
+   * @param string $public_name
+   *   (optional) The public field name. Defaults to the internal field name.
+   * @param bool $enabled
+   *   (optional) Whether the field is enabled. Defaults to TRUE.
+   * @param bool $has_one
+   *   (optional) Whether the field can only have ony value. Defaults to TRUE.
+   */
+  public function __construct($internal_name, $public_name = NULL, $enabled = TRUE, $has_one = TRUE) {
+    $this->internalName = $internal_name;
+    $this->publicName = $public_name ?: $internal_name;
+    $this->enabled = $enabled;
+    $this->hasOne = $has_one;
+  }
+
+  /**
+   * Gets the internal name of the field.
+   *
+   * @return string
+   *   The internal name of the field.
+   */
+  public function getInternalName() {
+    return $this->internalName;
+  }
+
+  /**
+   * Gets the public name of the field.
+   *
+   * @return string
+   *   The public name of the field.
+   */
+  public function getPublicName() {
+    return $this->publicName;
+  }
+
+  /**
+   * Establishes a new public name for the field.
+   *
+   * @param string $public_name
+   *   The public name.
+   *
+   * @return static
+   *   A new instance of the field with the given public name.
+   */
+  public function withPublicName($public_name) {
+    return new static($this->internalName, $public_name, $this->enabled, $this->hasOne);
+  }
+
+  /**
+   * Whether the field is enabled.
+   *
+   * @return bool
+   *   Whether the field is enabled. FALSE if the field should not be in the
+   *   JSON:API response.
+   */
+  public function isFieldEnabled() {
+    return $this->enabled;
+  }
+
+  /**
+   * Whether the field can only have one value.
+   *
+   * @return bool
+   *   TRUE if the field can have only one value, FALSE otherwise.
+   */
+  public function hasOne() {
+    return $this->hasOne;
+  }
+
+  /**
+   * Whether the field can have many values.
+   *
+   * @return bool
+   *   TRUE if the field can have more than one value, FALSE otherwise.
+   */
+  public function hasMany() {
+    return !$this->hasOne;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRelationship.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRelationship.php
new file mode 100644
index 000000000000..e62203ca2fe7
--- /dev/null
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRelationship.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\jsonapi\ResourceType;
+
+/**
+ * Specialization of a ResourceTypeField to represent a resource relationship.
+ *
+ * @internal JSON:API maintains no PHP API since its API is the HTTP API. This
+ *   class may change at any time and this will break any dependencies on it.
+ *
+ * @see https://www.drupal.org/project/jsonapi/issues/3032787
+ * @see jsonapi.api.php
+ *
+ * @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
+ */
+class ResourceTypeRelationship extends ResourceTypeField {
+
+  /**
+   * The resource type to which this relationships can relate.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType[]
+   */
+  protected $relatableResourceTypes;
+
+  /**
+   * Establishes the relatable resource types of this field.
+   *
+   * @param array $resource_types
+   *   The array of relatable resource types.
+   *
+   * @return static
+   *   A new instance of the field with the given relatable resource types.
+   */
+  public function withRelatableResourceTypes(array $resource_types) {
+    $relationship = new static($this->internalName, $this->publicName, $this->enabled, $this->hasOne);
+    $relationship->relatableResourceTypes = $resource_types;
+    return $relationship;
+  }
+
+  /**
+   * Gets the relatable resource types.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType[]
+   *   The resource type to which this relationships can relate.
+   */
+  public function getRelatableResourceTypes() {
+    if (!isset($this->relatableResourceTypes)) {
+      throw new \LogicException("withRelatableResourceTypes() must be called before getting relatable resource types.");
+    }
+    return $this->relatableResourceTypes;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function withPublicName($public_name) {
+    $relationship = parent::withPublicName($public_name);
+    return isset($this->relatableResourceTypes)
+      ? $relationship->withRelatableResourceTypes($this->relatableResourceTypes)
+      : $relationship;
+  }
+
+}
diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
index 400d31a6105c..b70ecee04d6a 100644
--- a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
+++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php
@@ -147,7 +147,7 @@ protected function createResourceType(EntityTypeInterface $entity_type, $bundle)
       static::isLocatableResourceType($entity_type, $bundle),
       static::isMutableResourceType($entity_type, $bundle),
       static::isVersionableResourceType($entity_type),
-      static::getFieldMapping($raw_fields, $entity_type, $bundle)
+      static::getFields($raw_fields, $entity_type, $bundle)
     );
   }
 
@@ -181,21 +181,14 @@ public function getByTypeName($type_name) {
    * @param string $bundle
    *   The bundle to assess.
    *
-   * @return array
-   *   An array with:
-   *   - keys are (real/internal) field names
-   *   - values are either FALSE (indicating the field is not exposed despite
-   *     not being internal), TRUE (indicating the field should be exposed under
-   *     its internal name) or a string (indicating the field should not be
-   *     exposed using its internal name, but the name specified in the string)
+   * @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
+   *   An array of JSON:API resource type fields keyed by internal field names.
    */
-  protected static function getFieldMapping(array $field_names, EntityTypeInterface $entity_type, $bundle) {
+  protected function getFields(array $field_names, EntityTypeInterface $entity_type, $bundle) {
     assert(Inspector::assertAllStrings($field_names));
     assert($entity_type instanceof ContentEntityTypeInterface || $entity_type instanceof ConfigEntityTypeInterface);
     assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
 
-    $mapping = [];
-
     // JSON:API resource identifier objects are sufficient to identify
     // entities. By exposing all fields as attributes, we expose unwanted,
     // confusing or duplicate information:
@@ -210,12 +203,12 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac
     // @see http://jsonapi.org/format/#document-resource-identifier-objects
     $id_field_name = $entity_type->getKey('id');
     $uuid_field_name = $entity_type->getKey('uuid');
-    if ($uuid_field_name !== 'id') {
-      $mapping[$uuid_field_name] = FALSE;
+    if ($uuid_field_name && $uuid_field_name !== 'id') {
+      $fields[$uuid_field_name] = new ResourceTypeAttribute($uuid_field_name, NULL, FALSE);
     }
-    $mapping[$id_field_name] = "drupal_internal__$id_field_name";
+    $fields[$id_field_name] = new ResourceTypeAttribute($id_field_name, "drupal_internal__$id_field_name");
     if ($entity_type->isRevisionable() && ($revision_id_field_name = $entity_type->getKey('revision'))) {
-      $mapping[$revision_id_field_name] = "drupal_internal__$revision_id_field_name";
+      $fields[$revision_id_field_name] = new ResourceTypeAttribute($revision_id_field_name, "drupal_internal__$revision_id_field_name");
     }
     if ($entity_type instanceof ConfigEntityTypeInterface) {
       // The '_core' key is reserved by Drupal core to handle complex edge cases
@@ -223,25 +216,46 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac
       // configuration, and is not allowed to be set by clients writing
       // configuration: it is for Drupal core only, and managed by Drupal core.
       // @see https://www.drupal.org/node/2653358
-      $mapping['_core'] = FALSE;
+      $fields['_core'] = new ResourceTypeAttribute('_core', NULL, FALSE);
+    }
+
+    $is_fieldable = $entity_type->entityClassImplements(FieldableEntityInterface::class);
+    if ($is_fieldable) {
+      $field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type->id(), $bundle);
     }
 
     // For all other fields,  use their internal field name also as their public
     // field name.  Unless they're called "id" or "type": those names are
     // reserved by the JSON:API spec.
     // @see http://jsonapi.org/format/#document-resource-object-fields
-    foreach (array_diff($field_names, array_keys($mapping)) as $field_name) {
-      if ($field_name === 'id' || $field_name === 'type') {
+    $reserved_field_names = ['id', 'type'];
+    foreach (array_diff($field_names, array_keys($fields)) as $field_name) {
+      $alias = $field_name;
+      // Alias the fields reserved by the JSON:API spec with `{entity_type}_`.
+      if (in_array($field_name, $reserved_field_names, TRUE)) {
         $alias = $entity_type->id() . '_' . $field_name;
-        if (in_array($alias, $field_names, TRUE)) {
-          throw new \LogicException("The generated alias '{$alias}' for field name '{$field_name}' conflicts with an existing field. Please report this in the JSON:API issue queue!");
-        }
-        $mapping[$field_name] = $alias;
-        continue;
       }
 
       // The default, which applies to most fields: expose as-is.
-      $mapping[$field_name] = TRUE;
+      $field_definition = $is_fieldable && !empty($field_definitions[$field_name]) ? $field_definitions[$field_name] : NULL;
+      $is_relationship_field = $field_definition && static::isReferenceFieldDefinition($field_definition);
+      $has_one = !$field_definition || $field_definition->getFieldStorageDefinition()->getCardinality() === 1;
+      $fields[$field_name] = $is_relationship_field
+        ? new ResourceTypeRelationship($field_name, $alias, TRUE, $has_one)
+        : new ResourceTypeAttribute($field_name, $alias, TRUE, $has_one);
+    }
+
+    // With all fields now aliased, detect any conflicts caused by the
+    // automatically generated aliases above.
+    foreach (array_intersect($reserved_field_names, array_keys($fields)) as $reserved_field_name) {
+      /* @var \Drupal\jsonapi\ResourceType\ResourceTypeField $aliased_reserved_field */
+      $aliased_reserved_field = $fields[$reserved_field_name];
+      /* @var \Drupal\jsonapi\ResourceType\ResourceTypeField $field */
+      foreach (array_diff_key($fields, array_flip([$reserved_field_name])) as $field) {
+        if ($aliased_reserved_field->getPublicName() === $field->getPublicName()) {
+          throw new \LogicException("The generated alias '{$aliased_reserved_field->getPublicName()}' for field name '{$aliased_reserved_field->getInternalName()}' conflicts with an existing field. Please report this in the JSON:API issue queue!");
+        }
+      }
     }
 
     // Special handling for user entities that allows a JSON:API user agent to
@@ -250,10 +264,47 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac
     // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
     // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
     if ($entity_type->id() === 'user') {
-      $mapping['display_name'] = TRUE;
+      $fields['display_name'] = new ResourceTypeAttribute('display_name');
     }
 
-    return $mapping;
+    return $fields;
+  }
+
+  /**
+   * Gets the field mapping for the given field names and entity type + bundle.
+   *
+   * @param string[] $field_names
+   *   All field names on a bundle of the given entity type.
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type for which to get the field mapping.
+   * @param string $bundle
+   *   The bundle to assess.
+   *
+   * @return array
+   *   An array with:
+   *   - keys are (real/internal) field names
+   *   - values are either FALSE (indicating the field is not exposed despite
+   *     not being internal), TRUE (indicating the field should be exposed under
+   *     its internal name) or a string (indicating the field should not be
+   *     exposed using its internal name, but the name specified in the string)
+   *
+   * @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
+   *   self::getFields() instead.
+   *
+   * @see https://www.drupal.org/project/drupal/issues/3014277
+   */
+  protected function getFieldMapping(array $field_names, EntityTypeInterface $entity_type, $bundle) {
+    $class_name = self::class;
+    @trigger_error("{$class_name}::getFieldMapping() is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. Use {$class_name}::getFields() instead. See https://www.drupal.org/project/drupal/issues/3014277.", E_USER_DEPRECATED);
+    $fields = $this->getFields($field_names, $entity_type, $bundle);
+    return array_map(function (ResourceTypeField $field) {
+      if ($field->isFieldEnabled()) {
+        return $field->getInternalName() !== $field->getPublicName()
+          ? $field->getPublicName()
+          : TRUE;
+      }
+      return FALSE;
+    }, $fields);
   }
 
   /**
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
index 33c9c1a4d1c0..39a3974c2501 100644
--- a/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_collection_count/src/ResourceType/CountableResourceTypeRepository.php
@@ -22,7 +22,8 @@ protected function createResourceType(EntityTypeInterface $entity_type, $bundle)
       $entity_type->isInternal(),
       static::isLocatableResourceType($entity_type, $bundle),
       static::isMutableResourceType($entity_type, $bundle),
-      static::getFieldMapping($raw_fields, $entity_type, $bundle)
+      static::isVersionableResourceType($entity_type),
+      static::getFields($raw_fields, $entity_type, $bundle)
     );
   }
 
diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_aliasing/src/ResourceType/AliasingResourceTypeRepository.php b/core/modules/jsonapi/tests/modules/jsonapi_test_field_aliasing/src/ResourceType/AliasingResourceTypeRepository.php
index 59850dad6c82..218deb042320 100644
--- a/core/modules/jsonapi/tests/modules/jsonapi_test_field_aliasing/src/ResourceType/AliasingResourceTypeRepository.php
+++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_aliasing/src/ResourceType/AliasingResourceTypeRepository.php
@@ -13,14 +13,14 @@ class AliasingResourceTypeRepository extends ResourceTypeRepository {
   /**
    * {@inheritdoc}
    */
-  protected static function getFieldMapping(array $field_names, EntityTypeInterface $entity_type, $bundle) {
-    $mapping = parent::getFieldMapping($field_names, $entity_type, $bundle);
-    foreach ($field_names as $field_name) {
+  protected function getFields(array $field_names, EntityTypeInterface $entity_type, $bundle) {
+    $fields = parent::getFields($field_names, $entity_type, $bundle);
+    foreach ($fields as $field_name => $field) {
       if (strpos($field_name, 'field_test_alias_') === 0) {
-        $mapping[$field_name] = 'field_test_alias';
+        $fields[$field_name] = $fields[$field_name]->withPublicName('field_test_alias');
       }
     }
-    return $mapping;
+    return $fields;
   }
 
 }
diff --git a/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
index 011ef97c85c1..b8c09f0f35eb 100644
--- a/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
+++ b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php
@@ -122,7 +122,7 @@ public function testMappingNameConflictCheck($field_name_list) {
     $entity_type = \Drupal::entityTypeManager()->getDefinition('node');
     $bundle = 'article';
     $reflection_class = new \ReflectionClass($this->resourceTypeRepository);
-    $reflection_method = $reflection_class->getMethod('getFieldMapping');
+    $reflection_method = $reflection_class->getMethod('getFields');
     $reflection_method->setAccessible(TRUE);
 
     $this->expectException(\LogicException::class);
diff --git a/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
index 97f0c06be45b..e0cd524b9770 100644
--- a/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
+++ b/core/modules/jsonapi/tests/src/Kernel/Serializer/SerializerTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\jsonapi\Kernel\Serializer;
 
 use Drupal\Core\Render\Markup;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
 use Drupal\jsonapi_test_data_type\TraversableObject;
 use Drupal\node\Entity\Node;
@@ -41,6 +42,13 @@ class SerializerTest extends JsonapiKernelTestBase {
    */
   protected $node;
 
+  /**
+   * A resource type for testing.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
   /**
    * The subject under test.
    *
@@ -80,6 +88,7 @@ protected function setUp() {
     ]);
     $this->node->save();
     $this->container->setAlias('sut', 'jsonapi.serializer');
+    $this->resourceType = $this->container->get('jsonapi.resource_type.repository')->get($this->node->getEntityTypeId(), $this->node->bundle());
     $this->sut = $this->container->get('sut');
   }
 
@@ -87,7 +96,10 @@ protected function setUp() {
    * @covers \Drupal\jsonapi\Serializer\Serializer::normalize
    */
   public function testFallbackNormalizer() {
-    $context = ['account' => $this->user];
+    $context = [
+      'account' => $this->user,
+      'resource_object' => ResourceObject::createFromEntity($this->resourceType, $this->node),
+    ];
 
     $value = $this->sut->normalize($this->node->field_text, 'api_json', $context);
     $this->assertTrue($value instanceof CacheableNormalization);
diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php
index 390d59d5cda2..8df76d65591a 100644
--- a/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php
+++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ResourceIdentifierNormalizerTest.php
@@ -13,6 +13,7 @@
 use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
 use Drupal\jsonapi\Normalizer\ResourceIdentifierNormalizer;
 use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
 use Drupal\Tests\UnitTestCase;
 use Prophecy\Argument;
@@ -45,7 +46,11 @@ class ResourceIdentifierNormalizerTest extends UnitTestCase {
    */
   public function setUp() {
     $target_resource_type = new ResourceType('lorem', 'dummy_bundle', NULL);
-    $this->resourceType = new ResourceType('fake_entity_type', 'dummy_bundle', NULL);
+    $relationship_fields = [
+      'field_dummy' => new ResourceTypeRelationship('field_dummy'),
+      'field_dummy_single' => new ResourceTypeRelationship('field_dummy_single'),
+    ];
+    $this->resourceType = new ResourceType('fake_entity_type', 'dummy_bundle', NULL, FALSE, TRUE, TRUE, FALSE, $relationship_fields);
     $this->resourceType->setRelatableResourceTypes([
       'field_dummy' => [$target_resource_type],
       'field_dummy_single' => [$target_resource_type],
diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
index e444a0bd3771..5ec52a6a9277 100644
--- a/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
+++ b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
 use Drupal\jsonapi\Routing\Routes;
 use Drupal\Tests\UnitTestCase;
@@ -30,8 +31,13 @@ class RoutesTest extends UnitTestCase {
    */
   protected function setUp() {
     parent::setUp();
-    $type_1 = new ResourceType('entity_type_1', 'bundle_1_1', EntityInterface::class);
-    $type_2 = new ResourceType('entity_type_2', 'bundle_2_1', EntityInterface::class, TRUE);
+    $relationship_fields = [
+      'external' => new ResourceTypeRelationship('external'),
+      'internal' => new ResourceTypeRelationship('internal'),
+      'both' => new ResourceTypeRelationship('both'),
+    ];
+    $type_1 = new ResourceType('entity_type_1', 'bundle_1_1', EntityInterface::class, FALSE, TRUE, TRUE, FALSE, $relationship_fields);
+    $type_2 = new ResourceType('entity_type_2', 'bundle_2_1', EntityInterface::class, TRUE, TRUE, TRUE, FALSE, $relationship_fields);
     $relatable_resource_types = [
       'external' => [$type_1],
       'internal' => [$type_2],
-- 
GitLab