.../jsonapi/src/Controller/EntityResource.php | 4 ++ .../JsonApiResource/LabelOnlyResourceObject.php | 14 ++++++- .../jsonapi/src/JsonApiResource/ResourceObject.php | 25 +++++------ .../src/Normalizer/ContentEntityDenormalizer.php | 5 +++ .../src/ResourceType/ResourceTypeRepository.php | 11 +++++ .../jsonapi/tests/src/Functional/MediaTest.php | 6 +++ .../jsonapi/tests/src/Functional/MessageTest.php | 5 --- .../tests/src/Functional/ResourceTestBase.php | 48 ++++++++++++---------- .../jsonapi/tests/src/Functional/UserTest.php | 5 ++- 9 files changed, 78 insertions(+), 45 deletions(-) diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 5c43d3ee24..b6a67a9e97 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -318,6 +318,10 @@ public function patchIndividual(ResourceType $resource_type, EntityInterface $en $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships'])); array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) { + // @see … + if ($resource_type->hasField('label_dynamic') && $field_name == $resource_type->getPublicName('label_dynamic')) { + return $destination; + } $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name); return $destination; }, $entity); diff --git a/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php index 157b9de1f2..69c86b1f40 100644 --- a/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php +++ b/core/modules/jsonapi/src/JsonApiResource/LabelOnlyResourceObject.php @@ -65,8 +65,18 @@ protected function setEntity(EntityInterface $entity) { */ protected static function extractFieldsFromEntity(ResourceType $resource_type, EntityInterface $entity) { $fields = parent::extractFieldsFromEntity($resource_type, $entity); - $public_label_field_name = $resource_type->getPublicName(static::getLabelFieldName($entity)); - return array_intersect_key($fields, [$public_label_field_name => TRUE]); + $label_fields = []; + if (static::getLabelFieldName($entity)) { + $label_fields[] = $resource_type->getPublicName(static::getLabelFieldName($entity)); + } + // @see … + if ($resource_type->hasField('label_dynamic')) { + $label_fields[] = $resource_type->getPublicName('label_dynamic'); + } + if (empty($label_fields)) { + throw new \LogicException(sprintf('The resource type %s does not have a label field.', $resource_type->getTypeName())); + } + return array_intersect_key($fields, array_flip($label_fields)); } } diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php index 0540248c9b..7dda0266e8 100644 --- a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php +++ b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php @@ -275,19 +275,18 @@ protected static function buildLinksFromEntity(ResourceType $resource_type, Enti protected static function extractContentEntityFields(ResourceType $resource_type, ContentEntityInterface $entity) { $output = []; $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData()); + + // Special handling for entity types that override label(). + if ($resource_type->hasField('label_dynamic')) { + $fields['label_dynamic'] = $entity->label(); + } + // Filter the array based on the field names. $enabled_field_names = array_filter( array_keys($fields), [$resource_type, 'isFieldEnabled'] ); - // Special handling for user entities. - // @todo Improve in https://www.drupal.org/project/drupal/issues/3057175. - $entity_type = $entity->getEntityType(); - if ($entity_type->id() == 'user') { - $fields[static::getLabelFieldName($entity)]->value = $entity->label(); - } - // Return a sub-array of $output containing the keys in $enabled_fields. $input = array_intersect_key($fields, array_flip($enabled_field_names)); foreach ($input as $field_name => $field_value) { @@ -303,16 +302,12 @@ protected static function extractContentEntityFields(ResourceType $resource_type * @param \Drupal\Core\Entity\EntityInterface $entity * The entity from which fields should be extracted. * - * @return string - * The label field name. + * @return string|false + * The label field name, or FALSE if the entity type does not have a label + * field. */ protected static function getLabelFieldName(EntityInterface $entity) { - $label_field_name = $entity->getEntityType()->getKey('label'); - // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands. - if ($entity->getEntityTypeId() === 'user') { - $label_field_name = 'name'; - } - return $label_field_name; + return $entity->getEntityType()->getKey('label'); } /** diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php index cd821d4975..7a6035e29f 100644 --- a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php +++ b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php @@ -58,6 +58,11 @@ protected function prepareInput(array $data, ResourceType $resource_type, $forma continue; } + // @see … + if ($internal_name === 'label_dynamic') { + continue; + } + if (!isset($field_map[$internal_name]) || !in_array($resource_type->getBundle(), $field_map[$internal_name]['bundles'], TRUE)) { throw new UnprocessableEntityHttpException(sprintf( 'The attribute %s does not exist on the %s resource type.', diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php index fea0e6aa38..86d7494a2d 100644 --- a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php +++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php @@ -6,8 +6,10 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\Entity\ConfigEntityTypeInterface; +use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\ContentEntityNullStorage; use Drupal\Core\Entity\ContentEntityTypeInterface; +use Drupal\Core\Entity\EntityBase; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -226,6 +228,15 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac $mapping['_core'] = FALSE; } + // Some entity types do not have a label field but only a label() callback, + // others have both a label field and a label() callback. Ensure that if a + // label() callback is present, that this is also exposed. + $reflection = new \ReflectionClass($entity_type->getClass()); + $label_declaring_class = $reflection->getMethod('label')->getDeclaringClass()->getName(); + if (!in_array($label_declaring_class, [EntityBase::class, ContentEntityBase::class], TRUE)) { + $mapping['label_dynamic'] = TRUE; + } + // 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. diff --git a/core/modules/jsonapi/tests/src/Functional/MediaTest.php b/core/modules/jsonapi/tests/src/Functional/MediaTest.php index ba7e63e662..ecb3ea0914 100644 --- a/core/modules/jsonapi/tests/src/Functional/MediaTest.php +++ b/core/modules/jsonapi/tests/src/Functional/MediaTest.php @@ -46,6 +46,11 @@ class MediaTest extends ResourceTestBase { */ protected $entity; + /** + * {@inheritdoc} + */ + protected static $labelFieldName = 'name'; + /** * {@inheritdoc} */ @@ -185,6 +190,7 @@ protected function getExpectedDocument() { 'revision_translation_affected' => TRUE, 'drupal_internal__mid' => 1, 'drupal_internal__vid' => 1, + 'label_dynamic' => 'llama.txt', ], 'relationships' => [ 'field_media_file' => [ diff --git a/core/modules/jsonapi/tests/src/Functional/MessageTest.php b/core/modules/jsonapi/tests/src/Functional/MessageTest.php index 9b9ddf71a9..dc69a1529c 100644 --- a/core/modules/jsonapi/tests/src/Functional/MessageTest.php +++ b/core/modules/jsonapi/tests/src/Functional/MessageTest.php @@ -38,11 +38,6 @@ class MessageTest extends ResourceTestBase { */ protected $entity; - /** - * {@inheritdoc} - */ - protected static $labelFieldName = 'subject'; - /** * {@inheritdoc} */ diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php index 6257adb4d8..bc5272bbcb 100644 --- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @@ -136,14 +136,11 @@ abstract class ResourceTestBase extends BrowserTestBase { protected static $secondCreatedEntityId = 3; /** - * Optionally specify which field is the 'label' field. - * - * Some entities specify a 'label_callback', but not a 'label' entity key. - * For example: User. + * Specify which field is the 'label' field for testing a POST edge case. * * @var string|null * - * @see ::getInvalidNormalizedEntityToCreate() + * @see ::testPostIndividual() */ protected static $labelFieldName = NULL; @@ -881,7 +878,7 @@ protected function makeNormalizationInvalid(array $document, $entity_key) { switch ($entity_key) { case 'label': // Add a second label to this entity to make it invalid. - $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName; + $label_field = $entity_type->getKey('label'); $document['data']['attributes'][$label_field] = [ 0 => $document['data']['attributes'][$label_field], 1 => 'Second Title', @@ -928,7 +925,7 @@ public function testGetIndividual() { } else { $expected_document = $this->getExpectedDocument(); - $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : 'label_dynamic'; $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]); unset($expected_document['data']['relationships']); // MISS or UNCACHEABLE depends on data. It must not be HIT. @@ -1883,7 +1880,9 @@ public function testPostIndividual() { $parseable_valid_request_body = Json::encode($this->getPostDocument()); /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */ $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type')); - $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); + if ($this->entity->getEntityType()->hasKey('label')) { + $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); + } $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument())); $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument())); $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument())); @@ -1939,13 +1938,15 @@ public function testPostIndividual() { $response = $this->request('POST', $url, $request_options); $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE); - $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + if ($this->entity->getEntityType()->hasKey('label')) { + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; - // DX: 422 when invalid entity: multiple values sent for single-value field. - $response = $this->request('POST', $url, $request_options); - $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; - $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); - $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->getKey('label'); + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + } $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; @@ -2050,6 +2051,7 @@ public function testPostIndividual() { // 500 when creating an entity with a duplicate UUID. $doc = $this->getModifiedEntityForPostTesting(); $doc['data']['id'] = $uuid; + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; $request_options[RequestOptions::BODY] = Json::encode($doc); @@ -2094,7 +2096,9 @@ public function testPatchIndividual() { $unparseable_request_body = '!{>}<'; $parseable_valid_request_body = Json::encode($this->getPatchDocument()); /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */ - $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label')); + if ($this->entity->getEntityType()->hasKey('label')) { + $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label')); + } $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPatchDocument())); // The 'field_rest_test' field does not allow 'view' access, so does not end // up in the JSON:API document. Even when we explicitly add it to the JSON @@ -2148,13 +2152,15 @@ public function testPatchIndividual() { $response = $this->request('PATCH', $url, $request_options); $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); - $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + if ($this->entity->getEntityType()->hasKey('label')) { + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; - // DX: 422 when invalid entity: multiple values sent for single-value field. - $response = $this->request('PATCH', $url, $request_options); - $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; - $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); - $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('PATCH', $url, $request_options); + $label_field = $this->entity->getEntityType()->getKey('label'); + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + } $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; diff --git a/core/modules/jsonapi/tests/src/Functional/UserTest.php b/core/modules/jsonapi/tests/src/Functional/UserTest.php index 1a72f7f2bf..3ce368bb40 100644 --- a/core/modules/jsonapi/tests/src/Functional/UserTest.php +++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php @@ -144,6 +144,7 @@ protected function getExpectedDocument() { 'langcode' => 'en', 'name' => 'Llama', 'drupal_internal__uid' => 3, + 'label_dynamic' => 'Llama', ], ], ]; @@ -205,7 +206,7 @@ public function testPatchDxForSecuritySensitiveBaseFields() { // @todo Remove the array_diff_key() call in https://www.drupal.org/node/2821077. $original_normalization['data']['attributes'] = array_diff_key( $original_normalization['data']['attributes'], - ['created' => TRUE, 'changed' => TRUE, 'name' => TRUE] + ['created' => TRUE, 'changed' => TRUE, 'label_dynamic' => TRUE] ); // Since this test must be performed by the user that is being modified, @@ -445,7 +446,7 @@ public function testCollectionContainsAnonymousUser() { $this->assertCount(4, $doc['data']); $this->assertSame(User::load(0)->uuid(), $doc['data'][0]['id']); - $this->assertSame('Anonymous', $doc['data'][0]['attributes']['name']); + $this->assertSame('Anonymous', $doc['data'][0]['attributes']['label_dynamic']); } /**