diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 8c0e027aa32d530ebc163acd90bff0cf3006acad..ca76ab31adf4cc1096d6987b9815dc876fa4a0fc 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -242,6 +242,13 @@ public function createIndividual(ResourceType $resource_type, Request $request) }, 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); if (!$field_access->isAllowed()) { @@ -317,6 +324,14 @@ public function patchIndividual(ResourceType $resource_type, EntityInterface $en $data += ['attributes' => [], 'relationships' => []]; $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships'])); + // 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 ($entity->getEntityTypeId() === 'user') { + $field_names = array_diff($field_names, [$resource_type->getPublicName('display_name')]); + } + array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) { $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name); return $destination; diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php index 0540248c9b9cd3f75261c4c34950b7f227e6732e..2505d006c20a8c9e79da1115266e825673398e26 100644 --- a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php +++ b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php @@ -15,6 +15,7 @@ use Drupal\jsonapi\ResourceType\ResourceType; use Drupal\jsonapi\Revisions\VersionByRel; use Drupal\jsonapi\Routing\Routes; +use Drupal\user\UserInterface; /** * Represents a JSON:API resource object. @@ -281,11 +282,15 @@ protected static function extractContentEntityFields(ResourceType $resource_type [$resource_type, 'isFieldEnabled'] ); - // Special handling for user entities. - // @todo Improve in https://www.drupal.org/project/drupal/issues/3057175. + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. For example, this is useful when + // displaying the name of a node's author. + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. $entity_type = $entity->getEntityType(); - if ($entity_type->id() == 'user') { - $fields[static::getLabelFieldName($entity)]->value = $entity->label(); + if ($entity_type->id() == 'user' && $resource_type->isFieldEnabled('display_name')) { + assert($entity instanceof UserInterface); + $display_name = $resource_type->getPublicName('display_name'); + $output[$display_name] = $entity->getDisplayName(); } // Return a sub-array of $output containing the keys in $enabled_fields. @@ -294,6 +299,7 @@ protected static function extractContentEntityFields(ResourceType $resource_type $public_field_name = $resource_type->getPublicName($field_name); $output[$public_field_name] = $field_value; } + return $output; } @@ -308,9 +314,13 @@ protected static function extractContentEntityFields(ResourceType $resource_type */ 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. + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. This is useful when displaying the + // name of a node's author. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. if ($entity->getEntityTypeId() === 'user') { - $label_field_name = 'name'; + $label_field_name = 'display_name'; } return $label_field_name; } diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php index cd821d497564d6dfac01fad14f8e5dddfd9e4ca7..287f2cb14050022770130c5a1d03573df07bb1ad 100644 --- a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php +++ b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php @@ -47,6 +47,14 @@ protected function prepareInput(array $data, ResourceType $resource_type, $forma $bundle_key = $entity_type_definition->getKey('bundle'); $uuid_key = $entity_type_definition->getKey('uuid'); + // 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 ($entity_type_id === 'user') { + $data = array_diff_key($data, array_flip([$resource_type->getPublicName('display_name')])); + } + // Translate the public fields into the entity fields. foreach ($data as $public_field_name => $field_value) { $internal_name = $resource_type->getInternalName($public_field_name); diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php index 48563444f53e6d94a851bf04c4702a9a97634070..ba609359dda94883bd7ffb1022f39bf183338989 100644 --- a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php +++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php @@ -244,6 +244,15 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac $mapping[$field_name] = TRUE; } + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. This is useful when displaying the + // name of a node's author. + // @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; + } + return $mapping; } diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..179eda4da52d6d80f87c00cb6594de92d05e153d --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml @@ -0,0 +1,4 @@ +name: 'JSON:API user tests' +type: module +package: Testing +core: 8.x diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module new file mode 100644 index 0000000000000000000000000000000000000000..4056bd64d269e7a83e4be8f03c56bdea5237a4b2 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Support module for JSON:API user hooks testing. + */ + +use Drupal\Core\Session\AccountInterface; + +/** + * Implements hook_user_format_name_alter(). + */ +function jsonapi_test_user_user_format_name_alter(&$name, AccountInterface $account) { + if ($account->isAnonymous()) { + $name = 'User ' . $account->id(); + } +} diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php index 6257adb4d82e7c933e53841ccd5efdc758c5f1b7..006e2b414718d59e2b4910392a32b5fe90afbbc6 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; @@ -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,14 @@ 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; - - // 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); + 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()->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 +2050,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); @@ -2148,13 +2149,14 @@ 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; - // 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); + if ($this->entity->getEntityType()->hasKey('label')) { + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + $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 2277714de8edce2af00f159e1c800e67efa81704..e9aa7b6bf319c6300f502ada5cb96114f944ed27 100644 --- a/core/modules/jsonapi/tests/src/Functional/UserTest.php +++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php @@ -22,7 +22,7 @@ class UserTest extends ResourceTestBase { /** * {@inheritdoc} */ - public static $modules = ['user']; + public static $modules = ['user', 'jsonapi_test_user']; /** * {@inheritdoc} @@ -56,7 +56,7 @@ class UserTest extends ResourceTestBase { /** * {@inheritdoc} */ - protected static $labelFieldName = 'name'; + protected static $labelFieldName = 'display_name'; /** * {@inheritdoc} @@ -138,6 +138,7 @@ protected function getExpectedDocument() { 'self' => ['href' => $self_url], ], 'attributes' => [ + 'display_name' => 'Llama', 'created' => '1973-11-29T21:33:09+00:00', 'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), 'default_langcode' => TRUE, @@ -441,7 +442,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('User 0', $doc['data'][0]['attributes']['display_name']); } /** @@ -551,4 +552,55 @@ public function testCollectionFilterAccess() { $this->assertSame($user_b->uuid(), $doc['data'][0]['id']); } + /** + * Tests users with altered display names. + */ + public function testResaveAccountName() { + $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); + $this->setUpAuthorization('PATCH'); + + $original_name = $this->entity->get('name')->value; + + $url = Url::fromRoute('jsonapi.user--user.individual', ['entity' => $this->entity->uuid()]); + $request_options = []; + $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); + + $response = $this->request('GET', $url, $request_options); + + // Send the unchanged data back. + $request_options[RequestOptions::BODY] = (string) $response->getBody(); + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; + $response = $this->request('PATCH', $url, $request_options); + $this->assertEquals(200, $response->getStatusCode()); + + // Load the user entity again, make sure the name was not changed. + $this->entityStorage->resetCache(); + $updated_user = $this->entityStorage->load($this->entity->id()); + $this->assertEquals($original_name, $updated_user->get('name')->value); + } + + /** + * {@inheritdoc} + */ + protected function getModifiedEntityForPostTesting() { + $modified = parent::getModifiedEntityForPostTesting(); + $modified['data']['attributes']['name'] = $this->randomMachineName(); + return $modified; + } + + /** + * {@inheritdoc} + */ + protected function makeNormalizationInvalid(array $document, $entity_key) { + if ($entity_key === 'label') { + $document['data']['attributes']['name'] = [ + 0 => $document['data']['attributes']['name'], + 1 => 'Second Title', + ]; + return $document; + } + return parent::makeNormalizationInvalid($document, $entity_key); + } + } diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php index b4ba1b3b1dc793fa0b2cb6816113258b85a44765..99bd557e41775119344eb2ade06491972934a10b 100644 --- a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php +++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -230,7 +230,7 @@ public function testNormalize() { 'field_image', ], 'user--user' => [ - 'name', + 'display_name', ], ], 'include' => [ @@ -279,7 +279,7 @@ public function testNormalize() { $this->assertTrue(empty($normalized['meta']['omitted'])); $this->assertSame($this->user->uuid(), $normalized['included'][0]['id']); $this->assertSame('user--user', $normalized['included'][0]['type']); - $this->assertSame('user1', $normalized['included'][0]['attributes']['name']); + $this->assertSame('user1', $normalized['included'][0]['attributes']['display_name']); $this->assertCount(1, $normalized['included'][0]['attributes']); $this->assertSame($this->term1->uuid(), $normalized['included'][1]['id']); $this->assertSame('taxonomy_term--tags', $normalized['included'][1]['type']);