From c7f5d70ec305f9a2a7e7bd54dde1b89b092806ce Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 10 Jun 2021 17:28:19 +0800 Subject: [PATCH 1/5] Add DataProducer plugins for to create, update and delete entities. --- .../DataProducer/Entity/CreateEntity.php | 92 +++++++++++++++++++ .../DataProducer/Entity/DeleteEntity.php | 46 ++++++++++ .../DataProducer/Entity/UpdateEntity.php | 78 ++++++++++++++++ .../DataProducer/EntityValidationTrait.php | 39 ++++++++ .../DataProducer/Entity/CreateEntityTest.php | 82 +++++++++++++++++ .../DataProducer/Entity/DeleteEntityTest.php | 59 ++++++++++++ .../DataProducer/Entity/UpdateEntityTest.php | 89 ++++++++++++++++++ 7 files changed, 485 insertions(+) create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php create mode 100644 src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php create mode 100644 tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php create mode 100644 tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php create mode 100644 tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php diff --git a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php new file mode 100644 index 000000000..3a3978df9 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php @@ -0,0 +1,92 @@ +entityTypeManager = $container->get('entity_type.manager'); + return $instance; + } + + /** + * Resolve the values for this producer. + */ + public function resolve(string $entity_type, array $values, string $entity_return_key, ?bool $save, $context) { + $storage = $this->entityTypeManager->getStorage($entity_type); + $accessHandler = $this->entityTypeManager->getAccessControlHandler($entity_type); + + // Ensure the user has access to create this kind of entity. + $access = $accessHandler->createAccess(NULL, NULL, [], TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'errors' => [$access instanceof AccessResultReasonInterface && $access->getReason() ? $access->getReason() : 'Access was forbidden.'], + ]; + } + + $entity = $storage->create($values); + if ($violation_messages = $this->getViolationMessages($entity)) { + return [ + 'errors' => $violation_messages, + ]; + } + + if ($save) { + $entity->save(); + } + return [ + $entity_return_key => $entity, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php new file mode 100644 index 000000000..7637af093 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php @@ -0,0 +1,46 @@ +access('delete', NULL, TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'was_successful' => FALSE, + 'errors' => [$access instanceof AccessResultReasonInterface ? $access->getReason() : 'Access was forbidden.'], + ]; + } + + $entity->delete(); + return [ + 'was_successful' => TRUE, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php new file mode 100644 index 000000000..add48963a --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php @@ -0,0 +1,78 @@ +access('update', NULL, TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'errors' => [$access instanceof AccessResultReasonInterface ? $access->getReason() : 'Access was forbidden.'], + ]; + } + + // Filter out keys the user does not have access to update, this may include + // things such as the owner of the entity or the ID of the entity. + $update_fields = array_filter($values, function (string $field_name) use ($entity, $context) { + $access = $entity->{$field_name}->access('edit', NULL, TRUE); + $context->addCacheableDependency($access); + return $access->isAllowed(); + }, ARRAY_FILTER_USE_KEY); + + // Hydrate the entity with the values. + foreach ($update_fields as $field_name => $field_value) { + $entity->set($field_name, $field_value); + } + + if ($violation_messages = $this->getViolationMessages($entity)) { + return [ + 'errors' => $violation_messages, + ]; + } + + // Once access has been granted, the save can be committed and the entity + // can be returned to the client. + $entity->save(); + return [ + $entity_return_key => $entity, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php new file mode 100644 index 000000000..828f9d71a --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php @@ -0,0 +1,39 @@ +validate(); + if ($violations->count() > 0) { + $violation_messages = []; + foreach ($violations as $violation) { + $violation_messages[] = sprintf('%s: %s', $violation->getPropertyPath(), strip_tags($violation->getMessage())); + } + return $violation_messages; + } + return NULL; + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php new file mode 100644 index 000000000..1bad6014e --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php @@ -0,0 +1,82 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('Access was forbidden.', $result['errors'][0]); + + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [ + 'type' => 'lorem' + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame([ + 'title: This value should not be null.', + ], $result['errors']); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'save' => TRUE, + 'values' => [ + 'type' => 'lorem', + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + $this->assertFalse($result['foo']->isNew()); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'save' => FALSE, + 'values' => [ + 'type' => 'lorem', + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + $this->assertTrue($result['foo']->isNew()); + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php b/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php new file mode 100644 index 000000000..86358c566 --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php @@ -0,0 +1,59 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + + $node = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + ]); + $node->save(); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $node, + ]); + $this->assertSame("The 'delete any lorem content' permission is required.", $result['errors'][0]); + + $account = $this->createUser(['bypass node access']); + $this->setCurrentUser($account); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $node, + ]); + $this->assertTrue($result['was_successful']); + $this->assertNull(Node::load($node->id())); + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php new file mode 100644 index 000000000..e9a66f5a3 --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php @@ -0,0 +1,89 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + + $entity = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + 'uuid' => 'adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', + ]); + $entity->save(); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame("The 'edit any lorem content' permission is required.", $result['errors'][0]); + + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'type' => 'something_wacky', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('type.0.target_id: The referenced entity (node_type: something_wacky) does not exist.', $result['errors'][0]); + + // Reload the article, since the data producer hydrates the passed in entity + // with values, but does not reset them. + $entity = Node::load($entity->id()); + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + + // Fields which do not pass field-access checks are filtered out. + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'uuid' => '1c41245d-d173-4861-8524-8dd50ef7668d', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertArrayNotHasKey('errors', $result); + $this->assertEquals('adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', $result['foo']->uuid()); + } + +} From 1aea77d85f1e908d489c7fae41e6a98a4544ddbb Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 14 Jun 2021 08:31:03 +0800 Subject: [PATCH 2/5] Check field create access and resolve some docs issues. --- .../DataProducer/Entity/CreateEntity.php | 25 ++++++++++++---- .../DataProducer/Entity/CreateEntityTest.php | 30 +++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php index 3a3978df9..7e3aad8c0 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php @@ -23,11 +23,11 @@ * required = TRUE * ), * "values" = @ContextDefinition("any", - * label = @Translation("Values to update"), + * label = @Translation("Field values for creating the entity"), * required = TRUE * ), * "entity_return_key" = @ContextDefinition("string", - * label = @Translation("Entity Return Key"), + * label = @Translation("Key name in the returned array where the entity will be placed"), * required = TRUE * ), * "save" = @ContextDefinition("boolean", @@ -70,15 +70,28 @@ public function resolve(string $entity_type, array $values, string $entity_retur $context->addCacheableDependency($access); if (!$access->isAllowed()) { return [ - 'errors' => [$access instanceof AccessResultReasonInterface && $access->getReason() ? $access->getReason() : 'Access was forbidden.'], + 'errors' => [$access instanceof AccessResultReasonInterface && $access->getReason() ? $access->getReason() : $this->t('Access was forbidden.')], ]; } $entity = $storage->create($values); + + // Core does not have a concept of create access for fields, so edit access + // is used instead. This is consistent with how other Drupal APIs handle + // field based create access. + $field_access_errors = []; + foreach ($values as $field_name => $value) { + $create_access = $entity->get($field_name)->access('edit', NULL, TRUE); + if (!$create_access->isALlowed()) { + $field_access_errors[] = sprintf('%s: %s', $field_name, $create_access instanceof AccessResultReasonInterface ? $create_access->getReason() : $this->t('Access was forbidden.')); + } + } + if (!empty($field_access_errors)) { + return ['errors' => $field_access_errors]; + } + if ($violation_messages = $this->getViolationMessages($entity)) { - return [ - 'errors' => $violation_messages, - ]; + return ['errors' => $violation_messages]; } if ($save) { diff --git a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php index 1bad6014e..5f013b4e8 100644 --- a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php +++ b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php @@ -25,21 +25,28 @@ class CreateEntityTest extends GraphQLTestBase { protected $pluginId = 'create_entity'; /** - * Test creating entities. + * {@inheritdoc} */ - public function testCreateEntity() { + protected function setUp(): void { + parent::setUp(); + $content_type = NodeType::create([ 'type' => 'lorem', 'name' => 'ipsum', ]); $content_type->save(); + } + /** + * Test creating entities. + */ + public function testCreateEntity() { $result = $this->executeDataProducer($this->pluginId, [ 'entity_type' => 'node', 'values' => [], 'entity_return_key' => 'foo', ]); - $this->assertSame('Access was forbidden.', $result['errors'][0]); + $this->assertSame('Access was forbidden.', (string) $result['errors'][0]); $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); @@ -79,4 +86,21 @@ public function testCreateEntity() { $this->assertTrue($result['foo']->isNew()); } + /** + * Test field access when creating entities. + */ + public function testCreateEntityFieldAccess() { + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [ + 'type' => 'lorem', + 'nid' => 123, + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('nid: The entity ID cannot be changed.', (string) $result['errors'][0]); + } + } From c7e59e556e6dc20ba09f12cd5167196abbd16b90 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 14 Jun 2021 08:39:46 +0800 Subject: [PATCH 3/5] Infer the missing bundle. --- .../GraphQL/DataProducer/Entity/CreateEntity.php | 12 +++++++++++- .../DataProducer/Entity/CreateEntityTest.php | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php index 7e3aad8c0..40f425fe0 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php @@ -65,8 +65,18 @@ public function resolve(string $entity_type, array $values, string $entity_retur $storage = $this->entityTypeManager->getStorage($entity_type); $accessHandler = $this->entityTypeManager->getAccessControlHandler($entity_type); + // Infer the bundle type from the response and return an error if the entity + // type expects one, but one is not present. + $entity_type = $this->entityTypeManager->getDefinition($entity_type); + $bundle = $entity_type->getKey('bundle') && !empty($values[$entity_type->getKey('bundle')]) ? $values[$entity_type->getKey('bundle')] : NULL; + if ($entity_type->getKey('bundle') && !$bundle) { + return [ + 'errors' => [$this->t('Entity type being created requried a bundle, but none was present.')], + ]; + } + // Ensure the user has access to create this kind of entity. - $access = $accessHandler->createAccess(NULL, NULL, [], TRUE); + $access = $accessHandler->createAccess($bundle, NULL, [], TRUE); $context->addCacheableDependency($access); if (!$access->isAllowed()) { return [ diff --git a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php index 5f013b4e8..1763e52fb 100644 --- a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php +++ b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php @@ -43,7 +43,9 @@ protected function setUp(): void { public function testCreateEntity() { $result = $this->executeDataProducer($this->pluginId, [ 'entity_type' => 'node', - 'values' => [], + 'values' => [ + 'type' => 'lorem', + ], 'entity_return_key' => 'foo', ]); $this->assertSame('Access was forbidden.', (string) $result['errors'][0]); @@ -103,4 +105,16 @@ public function testCreateEntityFieldAccess() { $this->assertSame('nid: The entity ID cannot be changed.', (string) $result['errors'][0]); } + /** + * Test creating an entity with a missing bundle. + */ + public function testCreateEntityMissingBundle() { + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('Entity type being created requried a bundle, but none was present.', (string) $result['errors'][0]); + } + } From acafe961bcdc9b86eb7f6f2928fe72c0dc568bcd Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 14 Jun 2021 08:45:11 +0800 Subject: [PATCH 4/5] Exception when updating field value that does not exist. --- .../DataProducer/Entity/UpdateEntity.php | 3 ++ .../DataProducer/Entity/UpdateEntityTest.php | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php index add48963a..5e8f55541 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php @@ -51,6 +51,9 @@ public function resolve(ContentEntityInterface $entity, array $values, string $e // Filter out keys the user does not have access to update, this may include // things such as the owner of the entity or the ID of the entity. $update_fields = array_filter($values, function (string $field_name) use ($entity, $context) { + if (!$entity->hasField($field_name)) { + throw new \Exception("Could not update '$field_name' field, since it does not exist on the given entity."); + } $access = $entity->{$field_name}->access('edit', NULL, TRUE); $context->addCacheableDependency($access); return $access->isAllowed(); diff --git a/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php index e9a66f5a3..48134a690 100644 --- a/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php +++ b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php @@ -26,15 +26,22 @@ class UpdateEntityTest extends GraphQLTestBase { protected $pluginId = 'update_entity'; /** - * Test updating an entity. + * {@inheritdoc} */ - public function testUpdateEntity() { + protected function setUp(): void { + parent::setUp(); + $content_type = NodeType::create([ 'type' => 'lorem', 'name' => 'ipsum', ]); $content_type->save(); + } + /** + * Test updating an entity. + */ + public function testUpdateEntity() { $entity = Node::create([ 'type' => 'lorem', 'title' => 'foo', @@ -86,4 +93,27 @@ public function testUpdateEntity() { $this->assertEquals('adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', $result['foo']->uuid()); } + /** + * Test updating an entity with an invalid field. + */ + public function testUpdateEntityInvalidField() { + $entity = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + 'uuid' => 'adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', + ]); + $entity->save(); + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $this->expectExceptionMessage("Could not update 'not_a_real_field' field, since it does not exist on the given entity."); + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'not_a_real_field' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame("The 'edit any lorem content' permission is required.", $result['errors'][0]); + } + } From 3253f08cc1e8f7b80be215732c38755fb023aa73 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 14 Jun 2021 08:59:33 +0800 Subject: [PATCH 5/5] Fiddle with violations. --- .../GraphQL/DataProducer/EntityValidationTrait.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php index 828f9d71a..0506b8890 100644 --- a/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php +++ b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php @@ -21,11 +21,16 @@ trait EntityValidationTrait { * @param \Drupal\Core\Entity\ContentEntityInterface $entity * An entity to validate. * - * @return array|null - * Get a list of violations or NULL if none were found. + * @return array + * Get a list of violations. */ - public function getViolationMessages(ContentEntityInterface $entity) { + public function getViolationMessages(ContentEntityInterface $entity): array { $violations = $entity->validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + if ($violations->count() > 0) { $violation_messages = []; foreach ($violations as $violation) { @@ -33,7 +38,7 @@ public function getViolationMessages(ContentEntityInterface $entity) { } return $violation_messages; } - return NULL; + return []; } }