From db158cc153200f27d4f06dca4f27908ee8fdea13 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Thu, 31 Aug 2023 10:47:14 +0200 Subject: [PATCH 01/18] test(phpstan): Fix node publish/unpublish calls in tests (#1354) --- .github/workflows/testing.yml | 2 +- phpstan.neon | 3 +++ .../Kernel/DataProducer/Routing/RouteEntityTest.php | 12 ++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 910b2ff28..e90e3ce6a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -86,7 +86,7 @@ jobs: webonyx/graphql-php:^14.8 \ drupal/typed_data:^1.0 \ drupal/redirect:^1.0 \ - phpstan/phpstan:^1.7.14 \ + phpstan/phpstan:^1.10.32 \ mglaman/phpstan-drupal:^1.1.2 \ phpstan/phpstan-deprecation-rules:^1.0.0 \ jangregor/phpstan-prophecy:^1.0.0 \ diff --git a/phpstan.neon b/phpstan.neon index 68be96082..ed80ea50f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,6 +12,9 @@ parameters: # Not sure we can specify generic types properly with Drupal coding standards # yet, disable for now. checkGenericClassInNonGenericObjectType: false + # Sometimes we have a mismatch between local execution and CI, we don't care + # about ignored errors that are not triggered. + reportUnmatchedIgnoredErrors: false excludePaths: # Exclude the RouteLoad producer because the redirect module is not D10 # compatible so we are not downloading it. diff --git a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php index 6e1a07bbc..fb91fc0d9 100644 --- a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php +++ b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php @@ -52,11 +52,11 @@ public function setUp(): void { $this->unpublished_node->save(); $this->translation_fr_unpublished = $this->unpublished_node->addTranslation('fr', ['title' => 'Test Unpublished Event FR']); - $this->translation_fr_unpublished->status = NodeInterface::NOT_PUBLISHED; + $this->translation_fr_unpublished->setUnpublished(); $this->translation_fr_unpublished->save(); $this->translation_de_unpublished = $this->unpublished_node->addTranslation('de', ['title' => 'Test Unpublished Event DE']); - $this->translation_de_unpublished->status = NodeInterface::NOT_PUBLISHED; + $this->translation_de_unpublished->setUnpublished(); $this->translation_de_unpublished->save(); // Unpublished node to published translations. @@ -68,11 +68,11 @@ public function setUp(): void { $this->unpublished_to_published_node->save(); $this->translation_fr_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('fr', ['title' => 'Test Unpublished to Published Event FR']); - $this->translation_fr_unpublished_to_published->status = NodeInterface::PUBLISHED; + $this->translation_fr_unpublished_to_published->setPublished(); $this->translation_fr_unpublished_to_published->save(); $this->translation_de_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('de', ['title' => 'Test Unpublished to Published Event DE']); - $this->translation_de_unpublished_to_published->status = NodeInterface::PUBLISHED; + $this->translation_de_unpublished_to_published->setPublished(); $this->translation_de_unpublished_to_published->save(); // Published node to unpublished translations. @@ -84,11 +84,11 @@ public function setUp(): void { $this->published_to_unpublished_node->save(); $this->translation_fr_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('fr', ['title' => 'Test Published to Unpublished Event FR']); - $this->translation_fr_published_to_unpublished->status = NodeInterface::NOT_PUBLISHED; + $this->translation_fr_published_to_unpublished->setUnpublished(); $this->translation_fr_published_to_unpublished->save(); $this->translation_de_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('de', ['title' => 'Test Published to Unpublished Event DE']); - $this->translation_de_published_to_unpublished->status = NodeInterface::NOT_PUBLISHED; + $this->translation_de_published_to_unpublished->setUnpublished(); $this->translation_de_published_to_unpublished->save(); \Drupal::service('content_translation.manager')->setEnabled('node', 'event', TRUE); From bb2f0c040eea93be2663552f3bffa65101dc8363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dezs=C5=91=20BICZ=C3=93?= Date: Mon, 4 Sep 2023 16:32:54 +0200 Subject: [PATCH 02/18] fix(DataProducer): Fix missing cacheability bubble up on entity translations data producer (#1353) --- .../GraphQL/DataProducer/Entity/EntityTranslation.php | 5 ++++- .../GraphQL/DataProducer/Entity/EntityTranslations.php | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php index 633bdc29d..1a30c65dc 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\TranslatableInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -97,10 +98,11 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, * @param bool|null $access * @param \Drupal\Core\Session\AccountInterface|null $accessUser * @param string|null $accessOperation + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return \Drupal\Core\Entity\EntityInterface|null */ - public function resolve(EntityInterface $entity, $language, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation) { + public function resolve(EntityInterface $entity, $language, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation, FieldContext $context) { if ($entity instanceof TranslatableInterface && $entity->isTranslatable()) { $entity = $entity->getTranslation($language); $entity->addCacheContexts(["static:language:{$language}"]); @@ -109,6 +111,7 @@ public function resolve(EntityInterface $entity, $language, ?bool $access, ?Acco if ($access) { /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ $accessResult = $entity->access($accessOperation, $accessUser, TRUE); + $context->addCacheableDependency($accessResult); if (!$accessResult->isAllowed()) { return NULL; } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php index 5a68d8e89..c78ac6b4b 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php @@ -9,6 +9,7 @@ use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -96,20 +97,22 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, * @param bool|null $access * @param \Drupal\Core\Session\AccountInterface|null $accessUser * @param string|null $accessOperation + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return array|null */ - public function resolve(EntityInterface $entity, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation) { + public function resolve(EntityInterface $entity, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation, FieldContext $context) { if ($entity instanceof TranslatableInterface && $entity->isTranslatable()) { $languages = $entity->getTranslationLanguages(); - return array_map(function (LanguageInterface $language) use ($entity, $access, $accessOperation, $accessUser) { + return array_map(function (LanguageInterface $language) use ($entity, $access, $accessOperation, $accessUser, $context) { $langcode = $language->getId(); $entity = $entity->getTranslation($langcode); $entity->addCacheContexts(["static:language:{$langcode}"]); if ($access) { /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ $accessResult = $entity->access($accessOperation, $accessUser, TRUE); + $context->addCacheableDependency($accessResult); if (!$accessResult->isAllowed()) { return NULL; } From fa8b191d3ed76e1ac9d084ff01501610e7aa6fda Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Thu, 21 Sep 2023 11:48:07 +0200 Subject: [PATCH 03/18] test(github): Test on latest Drupal 10.1 (#1361) --- .github/workflows/testing.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e90e3ce6a..12b07661c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -19,11 +19,11 @@ jobs: include: # Extra runs to also test on latest Drupal 10. - php-versions: '8.1' - drupal-core: '10.0.x' + drupal-core: '10.1.x' phpstan: '0' # We only need to run PHPStan once on the latest PHP version. - php-versions: '8.2' - drupal-core: '10.0.x' + drupal-core: '10.1.x' phpstan: '1' steps: - name: Checkout Drupal core From 9e8f44f8f1ee04c2dc38bfdbd99ddd9a158ab400 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Thu, 21 Sep 2023 14:19:17 +0200 Subject: [PATCH 04/18] test(github): Test in PHP development mode and fix deprecation warnings (#1362) --- .github/workflows/testing.yml | 1 + .../Fields/Image/ImageDerivativeTest.php | 44 ++++- .../Entity/Fields/Image/ImageUrlTest.php | 28 ++- .../DataProducer/EntityMultipleTest.php | 14 -- .../DataProducer/EntityReferenceTest.php | 41 ++-- tests/src/Kernel/DataProducer/EntityTest.php | 53 +++++- tests/src/Kernel/DataProducer/MenuTest.php | 19 +- .../DataProducer/Routing/RouteEntityTest.php | 176 +++++++++++++----- tests/src/Kernel/DataProducer/RoutingTest.php | 4 +- .../AutomaticPersistedQueriesTest.php | 11 +- .../Kernel/Framework/PersistedQueriesTest.php | 16 +- 11 files changed, 289 insertions(+), 118 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 12b07661c..b73f64bc9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -60,6 +60,7 @@ jobs: php-version: ${{ matrix.php-versions }} # Disable Xdebug for better performance. coverage: none + ini-file: development extensions: ${{ env.extensions }} - name: Get composer cache directory diff --git a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php index cfd9263d7..7d3f7862b 100644 --- a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php +++ b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php @@ -20,22 +20,50 @@ class ImageDerivativeTest extends GraphQLTestBase { */ protected static $modules = ['image', 'file']; + /** + * The file system URI under test. + * + * @var string + */ + protected $fileUri; + + /** + * The file entity mock. + * + * @var \Drupal\file\FileInterface + */ + protected $file; + + /** + * The image style for testing. + * + * @var \Drupal\image\Entity\ImageStyle + */ + protected $style; + + /** + * A file entity mock that returns FALSE on access checking. + * + * @var \Drupal\file\FileInterface + */ + protected $fileNotAccessible; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->file_uri = 'public://test.jpg'; + $this->fileUri = 'public://test.jpg'; $this->file = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file->method('getFileUri')->willReturn($this->file_uri); + $this->file->method('getFileUri')->willReturn($this->fileUri); $this->file->method('access')->willReturn((new AccessResultAllowed())->addCacheTags(['test_tag'])); - $this->file->width = 600; - $this->file->height = 400; + @$this->file->width = 600; + @$this->file->height = 400; $this->style = ImageStyle::create(['name' => 'test_style']); $effect = [ @@ -49,11 +77,11 @@ public function setUp(): void { $this->style->addImageEffect($effect); $this->style->save(); - $this->file_not_accessible = $this->getMockBuilder(FileInterface::class) + $this->fileNotAccessible = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file_not_accessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); + $this->fileNotAccessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); } /** @@ -69,7 +97,7 @@ public function testImageDerivative(): void { $this->assertEquals( [ - 'url' => $this->style->buildUrl($this->file_uri), + 'url' => $this->style->buildUrl($this->fileUri), 'width' => 300, 'height' => 200, ], @@ -83,7 +111,7 @@ public function testImageDerivative(): void { // Test that we don't get the derivative if we don't have access to the // original file, but we still get the access result cache tags. $result = $this->executeDataProducer('image_derivative', [ - 'entity' => $this->file_not_accessible, + 'entity' => $this->fileNotAccessible, 'style' => 'test_style', ]); diff --git a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php index 705da4363..24e10ba60 100644 --- a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php +++ b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php @@ -14,12 +14,32 @@ */ class ImageUrlTest extends GraphQLTestBase { + /** + * The file entity mock. + * + * @var \Drupal\file\FileInterface + */ + protected $file; + + /** + * A file entity mock that returns FALSE on access checking. + * + * @var \Drupal\file\FileInterface + */ + protected $fileNotAccessible; + + /** + * The generated file URI. + * + * @var string + */ + protected $fileUri; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->dataProducerManager = $this->container->get('plugin.manager.graphql.data_producer'); $this->fileUri = \Drupal::service('file_url_generator')->generateAbsoluteString('public://test.jpg'); @@ -30,11 +50,11 @@ public function setUp(): void { $this->file->method('getFileUri')->willReturn($this->fileUri); $this->file->method('access')->willReturn((new AccessResultAllowed())->addCacheTags(['test_tag'])); - $this->file_not_accessible = $this->getMockBuilder(FileInterface::class) + $this->fileNotAccessible = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file_not_accessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); + $this->fileNotAccessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); } /** @@ -53,7 +73,7 @@ public function testImageUrl(): void { // Test that we do not get a file we don't have access to, but the cache // tags are still added. $result = $this->executeDataProducer('image_url', [ - 'entity' => $this->file_not_accessible, + 'entity' => $this->fileNotAccessible, ]); $this->assertNull($result); diff --git a/tests/src/Kernel/DataProducer/EntityMultipleTest.php b/tests/src/Kernel/DataProducer/EntityMultipleTest.php index a363526b5..ef0e96c18 100644 --- a/tests/src/Kernel/DataProducer/EntityMultipleTest.php +++ b/tests/src/Kernel/DataProducer/EntityMultipleTest.php @@ -4,8 +4,6 @@ use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\node\NodeInterface; -use Drupal\Core\Entity\EntityInterface; -use Drupal\user\UserInterface; use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; @@ -42,18 +40,6 @@ class EntityMultipleTest extends GraphQLTestBase { public function setUp(): void { parent::setUp(); - $this->entity = $this->getMockBuilder(NodeInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->user = $this->getMockBuilder(UserInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $content_type = NodeType::create([ 'type' => 'lorem', 'name' => 'ipsum', diff --git a/tests/src/Kernel/DataProducer/EntityReferenceTest.php b/tests/src/Kernel/DataProducer/EntityReferenceTest.php index 3197dd8bd..081695ada 100644 --- a/tests/src/Kernel/DataProducer/EntityReferenceTest.php +++ b/tests/src/Kernel/DataProducer/EntityReferenceTest.php @@ -5,11 +5,8 @@ use Drupal\Tests\field\Traits\EntityReferenceTestTrait; use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; -use Drupal\node\NodeInterface; -use Drupal\Core\Entity\EntityInterface; use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; -use Drupal\user\UserInterface; /** * Tests the entity_reference data producers. @@ -19,24 +16,26 @@ class EntityReferenceTest extends GraphQLTestBase { use EntityReferenceTestTrait; + /** + * Test node that will be referenced. + * + * @var \Drupal\node\Entity\Node + */ + protected $referencedNode; + + /** + * Test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $node; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->entity = $this->getMockBuilder(NodeInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->user = $this->getMockBuilder(UserInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $content_type1 = NodeType::create([ 'type' => 'test1', 'name' => 'ipsum1', @@ -51,19 +50,19 @@ public function setUp(): void { $this->createEntityReferenceField('node', 'test1', 'field_test1_to_test2', 'test1 lable', 'node', 'default', [], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); - $this->referenced_node = Node::create([ + $this->referencedNode = Node::create([ 'title' => 'Dolor2', 'type' => 'test2', ]); - $this->referenced_node->save(); - $this->referenced_node + $this->referencedNode->save(); + $this->referencedNode ->addTranslation('fr', ['title' => 'Dolor2 French']) ->save(); $this->node = Node::create([ 'title' => 'Dolor', 'type' => 'test1', - 'field_test1_to_test2' => $this->referenced_node->id(), + 'field_test1_to_test2' => $this->referencedNode->id(), ]); $this->node->save(); } @@ -78,7 +77,7 @@ public function testResolveEntityReference(): void { 'access' => TRUE, 'access_operation' => 'view', ]); - $this->assertEquals($this->referenced_node->id(), reset($result)->id()); + $this->assertEquals($this->referencedNode->id(), reset($result)->id()); $this->assertEquals('Dolor2', reset($result)->label()); $result = $this->executeDataProducer('entity_reference', [ @@ -88,7 +87,7 @@ public function testResolveEntityReference(): void { 'access_operation' => 'view', 'language' => 'fr', ]); - $this->assertEquals($this->referenced_node->id(), reset($result)->id()); + $this->assertEquals($this->referencedNode->id(), reset($result)->id()); $this->assertEquals('Dolor2 French', reset($result)->label()); } diff --git a/tests/src/Kernel/DataProducer/EntityTest.php b/tests/src/Kernel/DataProducer/EntityTest.php index 5d9d053ca..15a0f00e6 100644 --- a/tests/src/Kernel/DataProducer/EntityTest.php +++ b/tests/src/Kernel/DataProducer/EntityTest.php @@ -24,6 +24,41 @@ class EntityTest extends GraphQLTestBase { */ protected $node; + /** + * Mocked test entity. + * + * @var \Drupal\node\NodeInterface|\PHPUnit\Framework\MockObject\MockObject + */ + protected $entity; + + /** + * Mocked test entity interface. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entityInterface; + + /** + * Mocked test user. + * + * @var \Drupal\user\UserInterface + */ + protected $user; + + /** + * Translated test entity. + * + * @var \Drupal\node\NodeInterface + */ + protected $translationFr; + + /** + * Translated test entity. + * + * @var \Drupal\node\NodeInterface + */ + protected $translationDe; + /** * {@inheritdoc} */ @@ -34,7 +69,7 @@ public function setUp(): void { ->disableOriginalConstructor() ->getMock(); - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) + $this->entityInterface = $this->getMockBuilder(EntityInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -64,11 +99,11 @@ public function setUp(): void { ]); $this->node->save(); - $this->translation_fr = $this->node->addTranslation('fr', ['title' => 'sit amet fr']); - $this->translation_fr->save(); + $this->translationFr = $this->node->addTranslation('fr', ['title' => 'sit amet fr']); + $this->translationFr->save(); - $this->translation_de = $this->node->addTranslation('de', ['title' => 'sit amet de']); - $this->translation_de->save(); + $this->translationDe = $this->node->addTranslation('de', ['title' => 'sit amet de']); + $this->translationDe->save(); \Drupal::service('content_translation.manager')->setEnabled('node', 'lorem', TRUE); } @@ -103,7 +138,7 @@ public function testResolveChanged(): void { $this->assertNull($this->executeDataProducer('entity_changed', [ 'format' => 'Y-m-d', - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -122,7 +157,7 @@ public function testResolveCreated(): void { $this->assertNull($this->executeDataProducer('entity_created', [ 'format' => 'Y-m-d', - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -199,7 +234,7 @@ public function testResolveOwner(): void { ])); $this->assertNull($this->executeDataProducer('entity_owner', [ - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -229,7 +264,7 @@ public function testResolvePublished(): void { ])); $this->assertNull($this->executeDataProducer('entity_published', [ - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } diff --git a/tests/src/Kernel/DataProducer/MenuTest.php b/tests/src/Kernel/DataProducer/MenuTest.php index 7dcb0c904..13daef986 100644 --- a/tests/src/Kernel/DataProducer/MenuTest.php +++ b/tests/src/Kernel/DataProducer/MenuTest.php @@ -20,6 +20,20 @@ class MenuTest extends GraphQLTestBase { */ protected $menuLinkManager; + /** + * Test menu. + * + * @var \Drupal\system\Entity\Menu + */ + protected $menu; + + /** + * Test link tree array. + * + * @var \Drupal\Core\Menu\MenuLinkTreeElement[] + */ + protected $linkTree; + /** * {@inheritdoc} */ @@ -57,7 +71,6 @@ public function setUp(): void { $link = MenuLinkContent::create($parent); $link->save(); $links['parent'] = $link->getPluginId(); - $this->testLink = $link; $child_1 = $base_options + [ 'link' => ['uri' => 'internal:/menu-test/hierarchy/parent/child'], @@ -91,8 +104,8 @@ public function setUp(): void { $link->save(); $links['child-2'] = $link->getPluginId(); - $this->menuLinkTree = $this->container->get('menu.link_tree'); - $this->linkTree = $this->menuLinkTree->load('menu_test', new MenuTreeParameters()); + $menuLinkTree = $this->container->get('menu.link_tree'); + $this->linkTree = $menuLinkTree->load('menu_test', new MenuTreeParameters()); } /** diff --git a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php index fb91fc0d9..76dec01a3 100644 --- a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php +++ b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php @@ -15,6 +15,90 @@ */ class RouteEntityTest extends GraphQLTestBase { + /** + * Published test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $publishedNode; + + /** + * French translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrPublished; + + /** + * German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDePublished; + + /** + * Unpublished test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $unpublishedNode; + + /** + * French translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrUnpublished; + + /** + * German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDeUnpublished; + + /** + * Unpublished test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $unpublishedToPublishedNode; + + /** + * Published french translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrUnpublishedToPublished; + + /** + * Published German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDeUnpublishedToPublished; + + /** + * Published test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $publishedToUnpublishedNode; + + /** + * Unpublished french translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrPublishedToUnpublished; + + /** + * Unpublished German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDePublishedToUnpublished; + /** * {@inheritdoc} */ @@ -30,66 +114,66 @@ public function setUp(): void { $content_type->save(); // Published node and published translations. - $this->published_node = Node::create([ + $this->publishedNode = Node::create([ 'title' => 'Test Event', 'type' => 'event', 'status' => NodeInterface::PUBLISHED, ]); - $this->published_node->save(); + $this->publishedNode->save(); - $this->translation_fr_published = $this->published_node->addTranslation('fr', ['title' => 'Test Event FR']); - $this->translation_fr_published->save(); + $this->translationFrPublished = $this->publishedNode->addTranslation('fr', ['title' => 'Test Event FR']); + $this->translationFrPublished->save(); - $this->translation_de_published = $this->published_node->addTranslation('de', ['title' => 'Test Event DE']); - $this->translation_de_published->save(); + $this->translationDePublished = $this->publishedNode->addTranslation('de', ['title' => 'Test Event DE']); + $this->translationDePublished->save(); // Unpublished node and unpublished translations. - $this->unpublished_node = Node::create([ + $this->unpublishedNode = Node::create([ 'title' => 'Test Unpublished Event', 'type' => 'event', 'status' => NodeInterface::NOT_PUBLISHED, ]); - $this->unpublished_node->save(); + $this->unpublishedNode->save(); - $this->translation_fr_unpublished = $this->unpublished_node->addTranslation('fr', ['title' => 'Test Unpublished Event FR']); - $this->translation_fr_unpublished->setUnpublished(); - $this->translation_fr_unpublished->save(); + $this->translationFrUnpublished = $this->unpublishedNode->addTranslation('fr', ['title' => 'Test Unpublished Event FR']); + $this->translationFrUnpublished->setUnpublished(); + $this->translationFrUnpublished->save(); - $this->translation_de_unpublished = $this->unpublished_node->addTranslation('de', ['title' => 'Test Unpublished Event DE']); - $this->translation_de_unpublished->setUnpublished(); - $this->translation_de_unpublished->save(); + $this->translationDeUnpublished = $this->unpublishedNode->addTranslation('de', ['title' => 'Test Unpublished Event DE']); + $this->translationDeUnpublished->setUnpublished(); + $this->translationDeUnpublished->save(); // Unpublished node to published translations. - $this->unpublished_to_published_node = Node::create([ + $this->unpublishedToPublishedNode = Node::create([ 'title' => 'Test Unpublished to Published Event', 'type' => 'event', 'status' => NodeInterface::NOT_PUBLISHED, ]); - $this->unpublished_to_published_node->save(); + $this->unpublishedToPublishedNode->save(); - $this->translation_fr_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('fr', ['title' => 'Test Unpublished to Published Event FR']); - $this->translation_fr_unpublished_to_published->setPublished(); - $this->translation_fr_unpublished_to_published->save(); + $this->translationFrUnpublishedToPublished = $this->unpublishedToPublishedNode->addTranslation('fr', ['title' => 'Test Unpublished to Published Event FR']); + $this->translationFrUnpublishedToPublished->setPublished(); + $this->translationFrUnpublishedToPublished->save(); - $this->translation_de_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('de', ['title' => 'Test Unpublished to Published Event DE']); - $this->translation_de_unpublished_to_published->setPublished(); - $this->translation_de_unpublished_to_published->save(); + $this->translationDeUnpublishedToPublished = $this->unpublishedToPublishedNode->addTranslation('de', ['title' => 'Test Unpublished to Published Event DE']); + $this->translationDeUnpublishedToPublished->setPublished(); + $this->translationDeUnpublishedToPublished->save(); // Published node to unpublished translations. - $this->published_to_unpublished_node = Node::create([ + $this->publishedToUnpublishedNode = Node::create([ 'title' => 'Test Published to Unpublished Event', 'type' => 'event', 'status' => NodeInterface::PUBLISHED, ]); - $this->published_to_unpublished_node->save(); + $this->publishedToUnpublishedNode->save(); - $this->translation_fr_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('fr', ['title' => 'Test Published to Unpublished Event FR']); - $this->translation_fr_published_to_unpublished->setUnpublished(); - $this->translation_fr_published_to_unpublished->save(); + $this->translationFrPublishedToUnpublished = $this->publishedToUnpublishedNode->addTranslation('fr', ['title' => 'Test Published to Unpublished Event FR']); + $this->translationFrPublishedToUnpublished->setUnpublished(); + $this->translationFrPublishedToUnpublished->save(); - $this->translation_de_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('de', ['title' => 'Test Published to Unpublished Event DE']); - $this->translation_de_published_to_unpublished->setUnpublished(); - $this->translation_de_published_to_unpublished->save(); + $this->translationDePublishedToUnpublished = $this->publishedToUnpublishedNode->addTranslation('de', ['title' => 'Test Published to Unpublished Event DE']); + $this->translationDePublishedToUnpublished->setUnpublished(); + $this->translationDePublishedToUnpublished->save(); \Drupal::service('content_translation.manager')->setEnabled('node', 'event', TRUE); } @@ -99,34 +183,34 @@ public function setUp(): void { */ public function testRouteEntity(): void { // Published node to published translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->published_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->publishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, ]); - $this->assertEquals($this->published_node->id(), $result->id()); - $this->assertEquals($this->published_node->label(), $result->label()); + $this->assertEquals($this->publishedNode->id(), $result->id()); + $this->assertEquals($this->publishedNode->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'fr', ]); - $this->assertEquals($this->translation_fr_published->id(), $result->id()); - $this->assertEquals($this->translation_fr_published->label(), $result->label()); + $this->assertEquals($this->translationFrPublished->id(), $result->id()); + $this->assertEquals($this->translationFrPublished->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'de', ]); - $this->assertEquals($this->translation_de_published->id(), $result->id()); - $this->assertEquals($this->translation_de_published->label(), $result->label()); + $this->assertEquals($this->translationDePublished->id(), $result->id()); + $this->assertEquals($this->translationDePublished->label(), $result->label()); // Unpublished node to unpublished translations. Make sure we are not // allowed to get the unpublished nodes or translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublished_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublishedNode->id()]); foreach ([NULL, 'fr', 'de'] as $lang) { $result = $this->executeDataProducer('route_entity', [ 'url' => $url, @@ -138,7 +222,7 @@ public function testRouteEntity(): void { // Unpublished node to published translations. Make sure we are not able to // get unpublished source, but we are able to get published translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublished_to_published_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublishedToPublishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, @@ -151,27 +235,27 @@ public function testRouteEntity(): void { 'language' => 'fr', ]); - $this->assertEquals($this->translation_fr_unpublished_to_published->id(), $result->id()); - $this->assertEquals($this->translation_fr_unpublished_to_published->label(), $result->label()); + $this->assertEquals($this->translationFrUnpublishedToPublished->id(), $result->id()); + $this->assertEquals($this->translationFrUnpublishedToPublished->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'de', ]); - $this->assertEquals($this->translation_de_unpublished_to_published->id(), $result->id()); - $this->assertEquals($this->translation_de_unpublished_to_published->label(), $result->label()); + $this->assertEquals($this->translationDeUnpublishedToPublished->id(), $result->id()); + $this->assertEquals($this->translationDeUnpublishedToPublished->label(), $result->label()); // Published node to unpublished translations. Make sure we are able to get // published source, but we are not able to get unpublished translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->published_to_unpublished_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->publishedToUnpublishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, ]); - $this->assertEquals($this->published_to_unpublished_node->id(), $result->id()); - $this->assertEquals($this->published_to_unpublished_node->label(), $result->label()); + $this->assertEquals($this->publishedToUnpublishedNode->id(), $result->id()); + $this->assertEquals($this->publishedToUnpublishedNode->label(), $result->label()); foreach (['fr', 'de'] as $lang) { $result = $this->executeDataProducer('route_entity', [ diff --git a/tests/src/Kernel/DataProducer/RoutingTest.php b/tests/src/Kernel/DataProducer/RoutingTest.php index d20ee1b1f..e523e7ca0 100644 --- a/tests/src/Kernel/DataProducer/RoutingTest.php +++ b/tests/src/Kernel/DataProducer/RoutingTest.php @@ -84,8 +84,8 @@ public function testRouteLoad(): void { * @covers \Drupal\graphql\Plugin\GraphQL\DataProducer\Routing\Url\UrlPath::resolve */ public function testUrlPath(): void { - $this->pathValidator = $this->container->get('path.validator'); - $url = $this->pathValidator->getUrlIfValidWithoutAccessCheck('/user/logout'); + $pathValidator = $this->container->get('path.validator'); + $url = $pathValidator->getUrlIfValidWithoutAccessCheck('/user/logout'); $result = $this->executeDataProducer('url_path', [ 'url' => $url, diff --git a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php index 58593ae0c..961f5559a 100644 --- a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php @@ -12,6 +12,13 @@ */ class AutomaticPersistedQueriesTest extends GraphQLTestBase { + /** + * Test plugin. + * + * @var \Drupal\graphql\Plugin\PersistedQueryPluginInterface + */ + protected $pluginApq; + /** * {@inheritdoc} */ @@ -32,7 +39,7 @@ protected function setUp(): void { /** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */ $manager = $this->container->get('plugin.manager.graphql.persisted_query'); - $this->plugin_apq = $manager->createInstance('automatic_persisted_query'); + $this->pluginApq = $manager->createInstance('automatic_persisted_query'); } /** @@ -42,7 +49,7 @@ public function testAutomaticPersistedQueries(): void { // Before adding the persisted query plugins to the server, we want to make // sure that there are no existing plugins already there. $this->server->removeAllPersistedQueryInstances(); - $this->server->addPersistedQueryInstance($this->plugin_apq); + $this->server->addPersistedQueryInstance($this->pluginApq); $this->server->save(); $endpoint = $this->server->get('endpoint'); diff --git a/tests/src/Kernel/Framework/PersistedQueriesTest.php b/tests/src/Kernel/Framework/PersistedQueriesTest.php index 43daeb249..913d20258 100644 --- a/tests/src/Kernel/Framework/PersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/PersistedQueriesTest.php @@ -44,13 +44,6 @@ protected function setUp(): void { $this->mockResolver('Query', 'field_three', []); $this->mockResolver('Link', 'url', 'https://www.ecosia.org'); $this->mockResolver('Link', 'title', 'Ecosia'); - - /** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */ - $manager = $this->container->get('plugin.manager.graphql.persisted_query'); - - $this->plugin_one = $manager->createInstance('persisted_query_plugin_one'); - $this->plugin_two = $manager->createInstance('persisted_query_plugin_two'); - $this->plugin_three = $manager->createInstance('persisted_query_plugin_three'); } /** @@ -62,9 +55,14 @@ public function testPersistedQueries(array $instanceIds, string $queryId, array // Before adding the persisted query plugins to the server, we want to make // sure that there are no existing plugins already there. $this->server->removeAllPersistedQueryInstances(); + + /** @var \Drupal\graphql\Plugin\PersistedQueryPluginManager $manager */ + $manager = $this->container->get('plugin.manager.graphql.persisted_query'); + foreach ($instanceIds as $index => $instanceId) { - $this->{$instanceId}->setWeight($index); - $this->server->addPersistedQueryInstance($this->{$instanceId}); + $plugin = $manager->createInstance("persisted_query_$instanceId"); + $plugin->setWeight($index); + $this->server->addPersistedQueryInstance($plugin); } $this->server->save(); From 4452ea6edb6b7cd07592218b1255d144d0c31449 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Thu, 21 Sep 2023 14:42:23 +0200 Subject: [PATCH 05/18] feat(dataproducers): Add entity query dataproducers (#1360) --- .../DataProducer/Entity/EntityQuery.php | 195 ++++++++++++++++++ .../DataProducer/Entity/EntityQueryBase.php | 141 +++++++++++++ .../DataProducer/Entity/EntityQueryCount.php | 138 +++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php create mode 100644 src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php new file mode 100644 index 000000000..e24412acb --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php @@ -0,0 +1,195 @@ + 'created', + * 'direction' => 'DESC', + * ], + * ]; + * $registry->addFieldResolver('Query', 'jobApplicationsByUserId', + * $builder->compose( + * $builder->fromArgument('id'), + * $builder->callback(function ($uid) { + * $conditions = [ + * [ + * 'field' => 'uid', + * 'value' => [$uid], + * ], + * ]; + * return $conditions; + * }), + * $builder->produce('entity_query', [ + * 'type' => $builder->fromValue('node'), + * 'conditions' => $builder->fromParent(), + * 'offset' => $builder->fromArgument('offset'), + * 'limit' => $builder->fromArgument('limit'), + * 'language' => $builder->fromArgument('language'), + * 'allowed_filters' => $builder->fromValue(['uid']), + * 'bundles' => $builder->fromValue(['job_application']), + * 'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting), + * ]), + * $builder->produce('entity_load_multiple', [ + * 'type' => $builder->fromValue('node'), + * 'ids' => $builder->fromParent(), + * ]), + * ) + * ); + * @endcode + * + * @DataProducer( + * id = "entity_query", + * name = @Translation("Load entities"), + * description = @Translation("Returns entity IDs for a given query"), + * produces = @ContextDefinition("string", + * label = @Translation("Entity IDs"), + * multiple = TRUE + * ), + * consumes = { + * "type" = @ContextDefinition("string", + * label = @Translation("Entity type") + * ), + * "limit" = @ContextDefinition("integer", + * label = @Translation("Limit"), + * required = FALSE, + * default_value = 10 + * ), + * "offset" = @ContextDefinition("integer", + * label = @Translation("Offset"), + * required = FALSE, + * default_value = 0 + * ), + * "owned_only" = @ContextDefinition("boolean", + * label = @Translation("Query only owned entities"), + * required = FALSE, + * default_value = FALSE + * ), + * "conditions" = @ContextDefinition("any", + * label = @Translation("Conditions"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "allowed_filters" = @ContextDefinition("string", + * label = @Translation("Allowed filters"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "languages" = @ContextDefinition("string", + * label = @Translation("Entity languages"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "bundles" = @ContextDefinition("any", + * label = @Translation("Entity bundles"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "access" = @ContextDefinition("boolean", + * label = @Translation("Check access"), + * required = FALSE, + * default_value = TRUE + * ), + * "sorts" = @ContextDefinition("any", + * label = @Translation("Sorts"), + * multiple = TRUE, + * default_value = {}, + * required = FALSE + * ) + * } + * ) + */ +class EntityQuery extends EntityQueryBase { + + /** + * The default maximum number of items to be capped to prevent DDOS attacks. + */ + const MAX_ITEMS = 100; + + /** + * Resolves the entity query. + * + * @param string $type + * Entity type. + * @param int $limit + * Maximum number of queried entities. + * @param int $offset + * Offset to start with. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param array $sorts + * List of sorts. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return array + * The list of ids that match this query. + * + * @throws \GraphQL\Error\UserError + * No bundles defined for given entity type. + */ + public function resolve(string $type, int $limit, int $offset, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, array $sorts, FieldContext $context): array { + $query = $this->buildBaseEntityQuery( + $type, + $ownedOnly, + $conditions, + $allowedFilters, + $languages, + $bundles, + $access, + $context + ); + + // Make sure offset is zero or positive. + $offset = max($offset, 0); + + // Make sure limit is positive and cap the max items to prevent DDOS + // attacks. + if ($limit <= 0) { + $limit = 10; + } + $limit = min($limit, self::MAX_ITEMS); + + // Apply offset and limit. + $query->range($offset, $limit); + + // Add sorts. + foreach ($sorts as $sort) { + if (!empty($sort['field'])) { + if (!empty($sort['direction']) && strtolower($sort['direction']) == 'desc') { + $direction = 'DESC'; + } + else { + $direction = 'ASC'; + } + $query->sort($sort['field'], $direction); + } + } + + $ids = $query->execute(); + + return $ids; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php new file mode 100644 index 000000000..4d0550062 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php @@ -0,0 +1,141 @@ +get('entity_type.manager'), + $container->get('current_user') + ); + } + + /** + * EntityLoad constructor. + * + * @param array $configuration + * The plugin configuration array. + * @param string $pluginId + * The plugin id. + * @param array $pluginDefinition + * The plugin definition array. + * @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager + * The entity type manager service. + * @param \Drupal\Core\Session\AccountProxyInterface $current_user + * The current user proxy. + */ + public function __construct( + array $configuration, + string $pluginId, + array $pluginDefinition, + EntityTypeManager $entityTypeManager, + AccountProxyInterface $current_user + ) { + parent::__construct($configuration, $pluginId, $pluginDefinition); + $this->entityTypeManager = $entityTypeManager; + $this->currentUser = $current_user; + } + + /** + * Build base entity query which may be reused for count query as well. + * + * @param string $type + * Entity type. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * Base entity query. + * + * @throws \GraphQL\Error\UserError + * No bundles defined for given entity type. + */ + protected function buildBaseEntityQuery(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): QueryInterface { + $entity_type = $this->entityTypeManager->getStorage($type); + $query = $entity_type->getQuery(); + + // Query only those entities which are owned by current user, if desired. + if ($ownedOnly) { + $query->condition('uid', $this->currentUser->id()); + // Add user cacheable dependencies. + $account = $this->currentUser->getAccount(); + $context->addCacheableDependency($account); + // Cache response per user to make sure the user related result is shown. + $context->addCacheContexts(['user']); + } + + // Ensure that desired access checking is performed on the query. + $query->accessCheck($access); + + // Filter entities only of given bundles, if desired. + if ($bundles) { + $bundle_key = $entity_type->getEntityType()->getKey('bundle'); + if (!$bundle_key) { + throw new UserError('No bundles defined for given entity type.'); + } + $query->condition($bundle_key, $bundles, 'IN'); + } + + // Filter entities by given languages, if desired. + if ($languages) { + $query->condition('langcode', $languages, 'IN'); + } + + // Filter by given conditions. + foreach ($conditions as $condition) { + if (!in_array($condition['field'], $allowedFilters)) { + throw new UserError("Field '{$condition['field']}' is not allowed as filter."); + } + $operation = $condition['operator'] ?? NULL; + $query->condition($condition['field'], $condition['value'], $operation); + } + + return $query; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php new file mode 100644 index 000000000..c7f783b1e --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php @@ -0,0 +1,138 @@ + 'created', + * 'direction' => 'DESC', + * ], + * ]; + * $registry->addFieldResolver('Query', 'jobApplicationsByUserIdCount', + * $builder->compose( + * $builder->fromArgument('id'), + * $builder->callback(function ($uid) { + * $conditions = [ + * [ + * 'field' => 'uid', + * 'value' => [$uid], + * ], + * ]; + * return $conditions; + * }), + * $builder->produce('entity_query_count', [ + * 'type' => $builder->fromValue('node'), + * 'conditions' => $builder->fromParent(), + * 'offset' => $builder->fromArgument('offset'), + * 'limit' => $builder->fromArgument('limit'), + * 'language' => $builder->fromArgument('language'), + * 'allowed_filters' => $builder->fromValue(['uid']), + * 'bundles' => $builder->fromValue(['job_application']), + * 'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting), + * ]) + * ) + * ); + * @endcode + * + * @DataProducer( + * id = "entity_query_count", + * name = @Translation("Load entities"), + * description = @Translation("Loads entities."), + * produces = @ContextDefinition("integer", + * label = @Translation("Total count of items queried by entity query."), + * ), + * consumes = { + * "type" = @ContextDefinition("string", + * label = @Translation("Entity type") + * ), + * "owned_only" = @ContextDefinition("boolean", + * label = @Translation("Query only owned entities"), + * required = FALSE, + * default_value = FALSE + * ), + * "conditions" = @ContextDefinition("any", + * label = @Translation("Conditions"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "allowed_filters" = @ContextDefinition("string", + * label = @Translation("Allowed filters"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "languages" = @ContextDefinition("string", + * label = @Translation("Entity languages"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "bundles" = @ContextDefinition("any", + * label = @Translation("Entity bundles"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "access" = @ContextDefinition("boolean", + * label = @Translation("Check access"), + * required = FALSE, + * default_value = TRUE + * ) + * } + * ) + */ +class EntityQueryCount extends EntityQueryBase { + + /** + * Resolves the entity query count. + * + * @param string $type + * Entity type. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return int + * Total count of items queried by entity query. + */ + public function resolve(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): int { + $query = $this->buildBaseEntityQuery( + $type, + $ownedOnly, + $conditions, + $allowedFilters, + $languages, + $bundles, + $access, + $context + ); + + return $query->count()->execute(); + } + +} From b9614c5b53e11c2cbf6f6a16a6f2ce461d37c47e Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Fri, 22 Sep 2023 13:05:50 +0200 Subject: [PATCH 06/18] style(coder): Upgrde Coder to 8.3.21 (#1363) --- .github/workflows/testing.yml | 48 +++++++++---------- .../GraphQL/DataProducer/CreateArticle.php | 2 +- src/Entity/Server.php | 8 ++-- src/Event/OperationEvent.php | 2 +- src/EventSubscriber/ApqSubscriber.php | 2 +- src/GraphQL/Execution/ExecutionResult.php | 2 +- src/GraphQL/Resolver/Composite.php | 4 +- src/GraphQL/ResolverBuilder.php | 2 +- src/GraphQL/ResolverRegistry.php | 2 +- src/GraphQL/Utility/FileUpload.php | 4 +- .../DataProducer/DataProducerPluginBase.php | 4 +- .../DataProducer/DataProducerProxy.php | 2 +- .../DataProducer/Entity/EntityAccess.php | 2 +- .../Entity/Fields/Image/ImageUrl.php | 2 +- .../Field/EntityReferenceLayoutRevisions.php | 2 +- .../Field/EntityReferenceRevisions.php | 2 +- .../Schema/AlterableComposableSchema.php | 6 +-- .../GraphQL/Schema/ComposableSchema.php | 2 +- src/Plugin/SchemaExtensionPluginInterface.php | 2 +- src/Plugin/SchemaPluginInterface.php | 2 +- .../graphql_file_validate.module | 2 +- .../DataProducer/EntityDefinitionTest.php | 4 +- .../DataProducer/EntityMultipleTest.php | 6 +-- .../DataProducer/EntityReferenceTest.php | 6 +-- tests/src/Kernel/DataProducer/EntityTest.php | 12 ++--- tests/src/Kernel/DataProducer/MenuTest.php | 8 ++-- .../Framework/DisabledResultCacheTest.php | 2 +- .../src/Kernel/Framework/ResultCacheTest.php | 6 +-- .../Kernel/Framework/TestFrameworkTest.php | 2 +- tests/src/Kernel/GraphQLTestBase.php | 2 +- tests/src/Kernel/ResolverBuilderTest.php | 2 +- tests/src/Traits/MockingTrait.php | 4 +- 32 files changed, 78 insertions(+), 80 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b73f64bc9..f18d2eeb8 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,7 +7,7 @@ on: jobs: drupal: name: Drupal ${{ matrix.drupal-core }} (PHP ${{ matrix.php-versions }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: extensions: mbstring, xml, pdo_sqlite, gd, opcache strategy: @@ -65,7 +65,7 @@ jobs: - name: Get composer cache directory id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies uses: actions/cache@v3 @@ -83,25 +83,11 @@ jobs: composer config --no-plugins allow-plugins.phpstan/extension-installer true - name: Install GraphQL dependencies - run: composer --no-interaction --no-progress require \ - webonyx/graphql-php:^14.8 \ - drupal/typed_data:^1.0 \ - drupal/redirect:^1.0 \ - phpstan/phpstan:^1.10.32 \ - mglaman/phpstan-drupal:^1.1.2 \ - phpstan/phpstan-deprecation-rules:^1.0.0 \ - jangregor/phpstan-prophecy:^1.0.0 \ - phpstan/phpstan-phpunit:^1.0.0 \ - phpstan/extension-installer:^1.0 - - # We install Coder separately because updating did not work in the local - # Drupal vendor dir. - - name: Install Coder run: | - mkdir coder - cd coder - echo '{"config": {"allow-plugins": {"dealerdirect/phpcodesniffer-composer-installer": true}}}' > composer.json - composer require drupal/coder:8.3.15 --no-interaction --no-progress + composer --no-interaction --no-progress require \ + webonyx/graphql-php:^14.8 \ + drupal/typed_data:^1.0 \ + drupal/redirect:^1.0 - name: Run PHPUnit run: | @@ -110,11 +96,23 @@ jobs: env: SIMPLETEST_DB: "sqlite://localhost/:memory:" + - name: Install PHPStan and Coder dependencies + if: ${{ matrix.phpstan == '1' }} + # Pin the exact Coder version to upgrade manually when we want to. + run: | + composer --no-interaction --no-progress require \ + phpstan/phpstan:^1.10.32 \ + mglaman/phpstan-drupal:^1.1.2 \ + phpstan/phpstan-deprecation-rules:^1.0.0 \ + jangregor/phpstan-prophecy:^1.0.0 \ + phpstan/phpstan-phpunit:^1.0.0 \ + phpstan/extension-installer:^1.0 + composer --no-interaction --no-progress --with-all-dependencies upgrade drupal/coder:8.3.21 + - name: Run PHPStan - # phpstan-drupal bug, so we remove 1 stub file - # https://github.com/mglaman/phpstan-drupal/issues/509 - run: if [[ ${{ matrix.phpstan }} == "1" ]]; then rm vendor/mglaman/phpstan-drupal/stubs/Drupal/Core/Field/FieldItemList.stub && cd modules/graphql && ../../vendor/bin/phpstan analyse; fi + if: ${{ matrix.phpstan == '1' }} + run: cd modules/graphql && ../../vendor/bin/phpstan analyse - name: Run PHPCS - run: | - cd modules/graphql && ../../coder/vendor/bin/phpcs -p + if: ${{ matrix.phpstan == '1' }} + run: cd modules/graphql && ../../vendor/bin/phpcs -p diff --git a/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php b/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php index 8200f5a10..1409c8b40 100644 --- a/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php +++ b/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php @@ -4,11 +4,11 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\graphql_composable\GraphQL\Response\ArticleResponse; use Drupal\node\Entity\Node; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; /** * Creates a new article entity. diff --git a/src/Entity/Server.php b/src/Entity/Server.php index 39449bb00..78511c4da 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -9,20 +9,20 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\graphql\GraphQL\Execution\ExecutionResult as CacheableExecutionResult; use Drupal\graphql\GraphQL\Execution\FieldContext; -use Drupal\graphql\Plugin\PersistedQueryPluginInterface; -use GraphQL\Error\DebugFlag; -use GraphQL\Server\OperationParams; use Drupal\graphql\GraphQL\Execution\ResolveContext; -use GraphQL\Server\ServerConfig; use Drupal\graphql\GraphQL\ResolverRegistryInterface; use Drupal\graphql\GraphQL\Utility\DeferredUtility; +use Drupal\graphql\Plugin\PersistedQueryPluginInterface; use Drupal\graphql\Plugin\SchemaPluginInterface; +use GraphQL\Error\DebugFlag; use GraphQL\Error\Error; use GraphQL\Error\FormattedError; use GraphQL\Executor\Executor; use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; use GraphQL\Language\AST\DocumentNode; use GraphQL\Server\Helper; +use GraphQL\Server\OperationParams; +use GraphQL\Server\ServerConfig; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Validator\DocumentValidator; use GraphQL\Validator\Rules\DisableIntrospection; diff --git a/src/Event/OperationEvent.php b/src/Event/OperationEvent.php index f51164b21..dd0326809 100644 --- a/src/Event/OperationEvent.php +++ b/src/Event/OperationEvent.php @@ -2,9 +2,9 @@ namespace Drupal\graphql\Event; +use Drupal\Component\EventDispatcher\Event; use Drupal\graphql\GraphQL\Execution\ResolveContext; use GraphQL\Executor\ExecutionResult; -use Drupal\Component\EventDispatcher\Event; /** * Represents an event that is triggered before and after a GraphQL operation. diff --git a/src/EventSubscriber/ApqSubscriber.php b/src/EventSubscriber/ApqSubscriber.php index 1dd3ca5a5..3594fd49b 100644 --- a/src/EventSubscriber/ApqSubscriber.php +++ b/src/EventSubscriber/ApqSubscriber.php @@ -4,8 +4,8 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\graphql\Event\OperationEvent; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; use GraphQL\Error\Error; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** * Save persisted queries to cache. diff --git a/src/GraphQL/Execution/ExecutionResult.php b/src/GraphQL/Execution/ExecutionResult.php index 127f04b01..113333d1e 100644 --- a/src/GraphQL/Execution/ExecutionResult.php +++ b/src/GraphQL/Execution/ExecutionResult.php @@ -2,9 +2,9 @@ namespace Drupal\graphql\GraphQL\Execution; -use GraphQL\Executor\ExecutionResult as LibraryExecutionResult; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\RefinableCacheableDependencyTrait; +use GraphQL\Executor\ExecutionResult as LibraryExecutionResult; /** * Expand the upstream ExecutionResult to make it Drupal cachable. diff --git a/src/GraphQL/Resolver/Composite.php b/src/GraphQL/Resolver/Composite.php index d72725812..fa9c1ac23 100644 --- a/src/GraphQL/Resolver/Composite.php +++ b/src/GraphQL/Resolver/Composite.php @@ -3,9 +3,9 @@ namespace Drupal\graphql\GraphQL\Resolver; use Drupal\graphql\GraphQL\Execution\FieldContext; -use GraphQL\Executor\Promise\Adapter\SyncPromise; -use Drupal\graphql\GraphQL\Utility\DeferredUtility; use Drupal\graphql\GraphQL\Execution\ResolveContext; +use Drupal\graphql\GraphQL\Utility\DeferredUtility; +use GraphQL\Executor\Promise\Adapter\SyncPromise; use GraphQL\Type\Definition\ResolveInfo; /** diff --git a/src/GraphQL/ResolverBuilder.php b/src/GraphQL/ResolverBuilder.php index 19b6722d9..6ebfdaf27 100644 --- a/src/GraphQL/ResolverBuilder.php +++ b/src/GraphQL/ResolverBuilder.php @@ -11,12 +11,12 @@ use Drupal\graphql\GraphQL\Resolver\DefaultValue; use Drupal\graphql\GraphQL\Resolver\Map; use Drupal\graphql\GraphQL\Resolver\ParentValue; +use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use Drupal\graphql\GraphQL\Resolver\SourceContext; use Drupal\graphql\GraphQL\Resolver\Tap; use Drupal\graphql\GraphQL\Resolver\Value; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerProxy; use Drupal\typed_data\DataFetcherTrait; -use Drupal\graphql\GraphQL\Resolver\ResolverInterface; /** * Wires and maps different resolvers together to build the GraphQL tree. diff --git a/src/GraphQL/ResolverRegistry.php b/src/GraphQL/ResolverRegistry.php index 30181e5b5..62c941476 100644 --- a/src/GraphQL/ResolverRegistry.php +++ b/src/GraphQL/ResolverRegistry.php @@ -4,11 +4,11 @@ use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\GraphQL\Execution\ResolveContext; +use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use GraphQL\Executor\Executor; use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use Drupal\graphql\GraphQL\Resolver\ResolverInterface; /** * Contains all the mappings how to resolve a GraphQL request. diff --git a/src/GraphQL/Utility/FileUpload.php b/src/GraphQL/Utility/FileUpload.php index bd22f7e0a..0422f2350 100644 --- a/src/GraphQL/Utility/FileUpload.php +++ b/src/GraphQL/Utility/FileUpload.php @@ -8,6 +8,7 @@ use Drupal\Component\Utility\Environment; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\File\Event\FileUploadSanitizeNameEvent; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Image\ImageFactory; @@ -20,10 +21,9 @@ use Drupal\Core\Utility\Token; use Drupal\file\FileInterface; use Drupal\graphql\GraphQL\Response\FileUploadResponse; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Drupal\Core\File\Event\FileUploadSanitizeNameEvent; use Symfony\Component\Mime\MimeTypeGuesserInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Service to manage file uploads within GraphQL mutations. diff --git a/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php b/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php index ac510dcf6..8ed6a429b 100644 --- a/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php +++ b/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php @@ -5,11 +5,11 @@ use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\PluginBase; use Drupal\Core\DependencyInjection\DependencySerializationTrait; -use Drupal\graphql\GraphQL\Execution\FieldContext; -use Drupal\graphql\Plugin\DataProducerPluginInterface; use Drupal\Core\Plugin\ContextAwarePluginTrait; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\TypedData\TypedDataTrait; +use Drupal\graphql\GraphQL\Execution\FieldContext; +use Drupal\graphql\Plugin\DataProducerPluginInterface; /** * Base class for data producers that resolve fields for queries or mutations. diff --git a/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php b/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php index 4e5747099..faf933aac 100644 --- a/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php +++ b/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php @@ -14,8 +14,8 @@ use Drupal\graphql\Plugin\DataProducerPluginCachingInterface; use Drupal\graphql\Plugin\DataProducerPluginInterface; use Drupal\graphql\Plugin\DataProducerPluginManager; -use Symfony\Component\HttpFoundation\RequestStack; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpFoundation\RequestStack; /** * A proxy class that lazy resolves data producers and has a result cache. diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php index c672fa109..7fa645259 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php @@ -2,8 +2,8 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity; -use Drupal\Core\Session\AccountInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; /** diff --git a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php index 84b1cfbc2..001cb0aec 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php @@ -3,13 +3,13 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity\Fields\Image; use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\file\FileInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\File\FileUrlGeneratorInterface; /** * Returns the file URL of a file entity. diff --git a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php index c09266dbc..8439eb20d 100644 --- a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php +++ b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php @@ -8,9 +8,9 @@ use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; -use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use GraphQL\Deferred; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php index b0f9a509a..0a2503a21 100644 --- a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php +++ b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php @@ -8,9 +8,9 @@ use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; -use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use GraphQL\Deferred; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php index 3e8d92df5..3088322cc 100644 --- a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php @@ -2,14 +2,14 @@ namespace Drupal\graphql\Plugin\GraphQL\Schema; +use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher; use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\graphql\Event\AlterSchemaDataEvent; use Drupal\graphql\Event\AlterSchemaExtensionDataEvent; use Drupal\graphql\Plugin\SchemaExtensionPluginInterface; -use GraphQL\Language\Parser; -use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\graphql\Plugin\SchemaExtensionPluginManager; +use GraphQL\Language\Parser; use Symfony\Component\DependencyInjection\ContainerInterface; /** diff --git a/src/Plugin/GraphQL/Schema/ComposableSchema.php b/src/Plugin/GraphQL/Schema/ComposableSchema.php index eeb439e47..ddb820f75 100644 --- a/src/Plugin/GraphQL/Schema/ComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/ComposableSchema.php @@ -6,8 +6,8 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\PluginFormInterface; -use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\graphql\GraphQL\ResolverRegistry; /** * @Schema( diff --git a/src/Plugin/SchemaExtensionPluginInterface.php b/src/Plugin/SchemaExtensionPluginInterface.php index 234aa19da..75eca1fcd 100644 --- a/src/Plugin/SchemaExtensionPluginInterface.php +++ b/src/Plugin/SchemaExtensionPluginInterface.php @@ -2,8 +2,8 @@ namespace Drupal\graphql\Plugin; -use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Component\Plugin\DerivativeInspectionInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\graphql\GraphQL\ResolverRegistryInterface; /** diff --git a/src/Plugin/SchemaPluginInterface.php b/src/Plugin/SchemaPluginInterface.php index d13c1e71d..ab8dbc33a 100644 --- a/src/Plugin/SchemaPluginInterface.php +++ b/src/Plugin/SchemaPluginInterface.php @@ -2,8 +2,8 @@ namespace Drupal\graphql\Plugin; -use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Component\Plugin\DerivativeInspectionInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\graphql\GraphQL\ResolverRegistryInterface; /** diff --git a/tests/modules/graphql_file_validate/graphql_file_validate.module b/tests/modules/graphql_file_validate/graphql_file_validate.module index 9e1db9dd9..ae89684be 100644 --- a/tests/modules/graphql_file_validate/graphql_file_validate.module +++ b/tests/modules/graphql_file_validate/graphql_file_validate.module @@ -10,7 +10,7 @@ use Drupal\file\FileInterface; /** * Implements hook_file_validate(). */ -function graphql_file_validate(FileInterface $file): void { +function graphql_file_validate_file_validate(FileInterface $file): void { if (!file_exists($file->getFileUri())) { throw new \Exception('File does not exist during validation: ' . $file->getFileUri()); } diff --git a/tests/src/Kernel/DataProducer/EntityDefinitionTest.php b/tests/src/Kernel/DataProducer/EntityDefinitionTest.php index beaa951cf..9dc325989 100644 --- a/tests/src/Kernel/DataProducer/EntityDefinitionTest.php +++ b/tests/src/Kernel/DataProducer/EntityDefinitionTest.php @@ -6,9 +6,9 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\Entity\NodeType; use Drupal\graphql\GraphQL\ResolverBuilder; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Test the entity_definition data producer and friends. diff --git a/tests/src/Kernel/DataProducer/EntityMultipleTest.php b/tests/src/Kernel/DataProducer/EntityMultipleTest.php index ef0e96c18..3d9598db0 100644 --- a/tests/src/Kernel/DataProducer/EntityMultipleTest.php +++ b/tests/src/Kernel/DataProducer/EntityMultipleTest.php @@ -2,10 +2,10 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\NodeInterface; -use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Data producers Entity multiple test class. diff --git a/tests/src/Kernel/DataProducer/EntityReferenceTest.php b/tests/src/Kernel/DataProducer/EntityReferenceTest.php index 081695ada..9d80fde6a 100644 --- a/tests/src/Kernel/DataProducer/EntityReferenceTest.php +++ b/tests/src/Kernel/DataProducer/EntityReferenceTest.php @@ -2,11 +2,11 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\field\Traits\EntityReferenceTestTrait; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; -use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\field\Traits\EntityReferenceTestTrait; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Tests the entity_reference data producers. diff --git a/tests/src/Kernel/DataProducer/EntityTest.php b/tests/src/Kernel/DataProducer/EntityTest.php index 15a0f00e6..b214f5047 100644 --- a/tests/src/Kernel/DataProducer/EntityTest.php +++ b/tests/src/Kernel/DataProducer/EntityTest.php @@ -2,15 +2,15 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\NodeInterface; use Drupal\Core\Entity\EntityInterface; -use Drupal\user\UserInterface; -use Drupal\node\Entity\NodeType; -use Drupal\node\Entity\Node; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\entity_test\Entity\EntityTestBundle; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; +use Drupal\user\UserInterface; /** * Data producers Entity test class. diff --git a/tests/src/Kernel/DataProducer/MenuTest.php b/tests/src/Kernel/DataProducer/MenuTest.php index 13daef986..f1c6c51fd 100644 --- a/tests/src/Kernel/DataProducer/MenuTest.php +++ b/tests/src/Kernel/DataProducer/MenuTest.php @@ -2,11 +2,11 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\system\Entity\Menu; -use Drupal\menu_link_content\Entity\MenuLinkContent; -use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Menu\MenuLinkTreeElement; +use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\menu_link_content\Entity\MenuLinkContent; +use Drupal\system\Entity\Menu; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Data producers Menu test class. diff --git a/tests/src/Kernel/Framework/DisabledResultCacheTest.php b/tests/src/Kernel/Framework/DisabledResultCacheTest.php index 4b8ba1081..31d5653cd 100644 --- a/tests/src/Kernel/Framework/DisabledResultCacheTest.php +++ b/tests/src/Kernel/Framework/DisabledResultCacheTest.php @@ -3,8 +3,8 @@ namespace Drupal\Tests\graphql\Kernel\Framework; use Drupal\Core\DependencyInjection\ContainerBuilder; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\graphql\Entity\Server; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Test disabled result cache. diff --git a/tests/src/Kernel/Framework/ResultCacheTest.php b/tests/src/Kernel/Framework/ResultCacheTest.php index 30ea99b98..e44e18f75 100644 --- a/tests/src/Kernel/Framework/ResultCacheTest.php +++ b/tests/src/Kernel/Framework/ResultCacheTest.php @@ -2,15 +2,15 @@ namespace Drupal\Tests\graphql\Kernel\Framework; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\Cache\Context\ContextCacheKeys; use Drupal\Core\Render\RenderContext; +use Drupal\graphql\Entity\Server; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Prophecy\Argument; -use Drupal\graphql\Entity\Server; -use Drupal\Core\Cache\CacheableDependencyInterface; use GraphQL\Deferred; +use Prophecy\Argument; /** * Test query result caching. diff --git a/tests/src/Kernel/Framework/TestFrameworkTest.php b/tests/src/Kernel/Framework/TestFrameworkTest.php index 824cc1c63..51802d268 100644 --- a/tests/src/Kernel/Framework/TestFrameworkTest.php +++ b/tests/src/Kernel/Framework/TestFrameworkTest.php @@ -2,9 +2,9 @@ namespace Drupal\Tests\graphql\Kernel\Framework; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\Core\Cache\CacheableDependencyInterface; /** * Test the test framework. diff --git a/tests/src/Kernel/GraphQLTestBase.php b/tests/src/Kernel/GraphQLTestBase.php index bb8416d9c..5a1a259cd 100644 --- a/tests/src/Kernel/GraphQLTestBase.php +++ b/tests/src/Kernel/GraphQLTestBase.php @@ -10,8 +10,8 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\graphql\Traits\DataProducerExecutionTrait; -use Drupal\Tests\graphql\Traits\MockingTrait; use Drupal\Tests\graphql\Traits\HttpRequestTrait; +use Drupal\Tests\graphql\Traits\MockingTrait; use Drupal\Tests\graphql\Traits\QueryFileTrait; use Drupal\Tests\graphql\Traits\QueryResultAssertionTrait; use Drupal\Tests\graphql\Traits\SchemaPrinterTrait; diff --git a/tests/src/Kernel/ResolverBuilderTest.php b/tests/src/Kernel/ResolverBuilderTest.php index dd4808e33..1365a7d90 100644 --- a/tests/src/Kernel/ResolverBuilderTest.php +++ b/tests/src/Kernel/ResolverBuilderTest.php @@ -2,8 +2,8 @@ namespace Drupal\Tests\graphql\Kernel; -use GraphQL\Deferred; use Drupal\graphql\GraphQL\Resolver\ResolverInterface; +use GraphQL\Deferred; /** * Tests that the resolver builder behaves correctly. diff --git a/tests/src/Traits/MockingTrait.php b/tests/src/Traits/MockingTrait.php index 1f1691db7..85f3a74a8 100644 --- a/tests/src/Traits/MockingTrait.php +++ b/tests/src/Traits/MockingTrait.php @@ -2,14 +2,14 @@ namespace Drupal\Tests\graphql\Traits; +use Drupal\graphql\Entity\Server; use Drupal\graphql\GraphQL\Resolver\Callback; use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use Drupal\graphql\GraphQL\Resolver\Value; +use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\graphql\Plugin\GraphQL\Schema\SdlSchemaPluginBase; use Drupal\graphql\Plugin\SchemaExtensionPluginManager; use Drupal\graphql\Plugin\SchemaPluginManager; -use Drupal\graphql\Entity\Server; -use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\Tests\RandomGeneratorTrait; /** From ce2bc1f7103326f2377eb5aa2384e58bf3edd402 Mon Sep 17 00:00:00 2001 From: Alexander Tkachev Date: Fri, 22 Sep 2023 15:18:54 +0400 Subject: [PATCH 07/18] fix(Condition): Fix array_pad() call with NULL values (#1340) --- src/GraphQL/Resolver/Condition.php | 3 ++- tests/src/Kernel/ResolverBuilderTest.php | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/GraphQL/Resolver/Condition.php b/src/GraphQL/Resolver/Condition.php index b86984f1a..4eb95b5ee 100644 --- a/src/GraphQL/Resolver/Condition.php +++ b/src/GraphQL/Resolver/Condition.php @@ -37,7 +37,8 @@ public function __construct(array $branches) { */ public function resolve($value, $args, ResolveContext $context, ResolveInfo $info, FieldContext $field) { $branches = $this->branches; - while ([$condition, $resolver] = array_pad(array_shift($branches), 2, NULL)) { + while ($branch = array_shift($branches)) { + [$condition, $resolver] = array_pad($branch, 2, NULL); if ($condition instanceof ResolverInterface) { if (($condition = $condition->resolve($value, $args, $context, $info, $field)) === NULL) { // Bail out early if a resolver returns NULL. diff --git a/tests/src/Kernel/ResolverBuilderTest.php b/tests/src/Kernel/ResolverBuilderTest.php index 1365a7d90..d800923fe 100644 --- a/tests/src/Kernel/ResolverBuilderTest.php +++ b/tests/src/Kernel/ResolverBuilderTest.php @@ -231,6 +231,26 @@ public function testFromContext(): void { $this->assertResults($query, [], ['tree' => ['context' => ['myContext' => 'my context value']]]); } + /** + * @covers ::cond + */ + public function testSingleCond(): void { + $this->mockResolver('Query', 'me', $this->builder->cond([ + [ + $this->builder->fromValue(FALSE), + $this->builder->fromValue('This should resolve into null.'), + ], + ])); + + $query = <<assertResults($query, [], ['me' => NULL]); + } + /** * @covers ::cond */ From f55a29d5acbaa777ab81e0295e11174a0f69e4d4 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Sun, 15 Oct 2023 11:46:04 +0200 Subject: [PATCH 08/18] test(phpstan): Ignore image derivative test warnings for now (#1368) --- .github/workflows/testing.yml | 2 +- phpstan.neon | 10 ---------- .../Entity/Fields/Image/ImageDerivative.php | 2 +- .../Entity/Fields/Image/ImageDerivativeTest.php | 3 +++ 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f18d2eeb8..f677f8f64 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -101,7 +101,7 @@ jobs: # Pin the exact Coder version to upgrade manually when we want to. run: | composer --no-interaction --no-progress require \ - phpstan/phpstan:^1.10.32 \ + phpstan/phpstan:^1.10.38 \ mglaman/phpstan-drupal:^1.1.2 \ phpstan/phpstan-deprecation-rules:^1.0.0 \ jangregor/phpstan-prophecy:^1.0.0 \ diff --git a/phpstan.neon b/phpstan.neon index ed80ea50f..235c373e0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,9 +12,6 @@ parameters: # Not sure we can specify generic types properly with Drupal coding standards # yet, disable for now. checkGenericClassInNonGenericObjectType: false - # Sometimes we have a mismatch between local execution and CI, we don't care - # about ignored errors that are not triggered. - reportUnmatchedIgnoredErrors: false excludePaths: # Exclude the RouteLoad producer because the redirect module is not D10 # compatible so we are not downloading it. @@ -54,10 +51,3 @@ parameters: message: "#^Method Drupal\\\\graphql\\\\Entity\\\\ServerInterface\\:\\:removePersistedQueryInstance\\(\\) has no return type specified\\.$#" count: 1 path: src/Entity/ServerInterface.php - - # We already use the ProphecyTrait, so not sure why PHPStan complains about - # this? - - """ - #^Call to deprecated method prophesize\\(\\) of class Drupal\\\\Tests\\\\graphql\\\\Kernel\\\\GraphQLTestBase\\: - https\\://github\\.com/sebastianbergmann/phpunit/issues/4141$# - """ diff --git a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php index 04a16bf8c..d2d7449de 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php @@ -89,7 +89,7 @@ public function __construct( * @return array|null */ public function resolve(FileInterface $entity = NULL, $style, RefinableCacheableDependencyInterface $metadata) { - // Return if we dont have an entity. + // Return if we don't have an entity. if (!$entity) { return NULL; } diff --git a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php index 7d3f7862b..18ef12b7c 100644 --- a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php +++ b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php @@ -62,7 +62,10 @@ public function setUp(): void { $this->file->method('getFileUri')->willReturn($this->fileUri); $this->file->method('access')->willReturn((new AccessResultAllowed())->addCacheTags(['test_tag'])); + // @todo Remove hard-coded properties and only rely on image factory. + // @phpstan-ignore-next-line @$this->file->width = 600; + // @phpstan-ignore-next-line @$this->file->height = 400; $this->style = ImageStyle::create(['name' => 'test_style']); From 237652097030c824f5313cfd1b8a476eeb5e0419 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Wed, 8 Nov 2023 13:56:41 +0100 Subject: [PATCH 09/18] fix(routing): Fix handling of POST requests --- graphql.services.yml | 2 +- src/Routing/QueryRouteEnhancer.php | 97 ++++++++++ .../AutomaticPersistedQueriesTest.php | 4 +- tests/src/Kernel/Framework/CsrfTest.php | 181 ++++++++++++++++++ tests/src/Traits/HttpRequestTrait.php | 4 +- 5 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 tests/src/Kernel/Framework/CsrfTest.php diff --git a/graphql.services.yml b/graphql.services.yml index 39a5eed99..ad0c2c9a7 100644 --- a/graphql.services.yml +++ b/graphql.services.yml @@ -87,7 +87,7 @@ services: # Upcasting for graphql query request parameters. graphql.route_enhancer.query: class: Drupal\graphql\Routing\QueryRouteEnhancer - arguments: ['@request_stack'] + arguments: ['%cors.config%'] tags: - { name: route_enhancer } diff --git a/src/Routing/QueryRouteEnhancer.php b/src/Routing/QueryRouteEnhancer.php index f2508edb3..0d8fec5dd 100644 --- a/src/Routing/QueryRouteEnhancer.php +++ b/src/Routing/QueryRouteEnhancer.php @@ -2,12 +2,14 @@ namespace Drupal\graphql\Routing; +use Asm89\Stack\CorsService; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Routing\EnhancerInterface; use Drupal\Core\Routing\RouteObjectInterface; use Drupal\graphql\GraphQL\Utility\JsonHelper; use GraphQL\Server\Helper; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Route; /** @@ -15,6 +17,20 @@ */ class QueryRouteEnhancer implements EnhancerInterface { + /** + * The CORS options for Origin header checking. + * + * @var array + */ + protected $corsOptions; + + /** + * Constructor. + */ + public function __construct(array $corsOptions) { + $this->corsOptions = $corsOptions; + } + /** * Returns whether the enhancer runs on the current route. * @@ -38,6 +54,10 @@ public function enhance(array $defaults, Request $request) { return $defaults; } + if ($request->getMethod() === "POST") { + $this->assertValidPostRequestHeaders($request); + } + $helper = new Helper(); $method = $request->getMethod(); $body = $this->extractBody($request); @@ -47,6 +67,83 @@ public function enhance(array $defaults, Request $request) { return $defaults + ['operations' => $operations]; } + /** + * Ensures that the headers for a POST request have triggered a preflight. + * + * POST requests must be submitted with content-type headers that properly + * trigger a cross-origin preflight request. In case content-headers are used + * that would trigger a "simple" request then custom headers must be provided. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request to check. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * In case the headers indicated a preflight was not performed. + */ + protected function assertValidPostRequestHeaders(Request $request) : void { + $content_type = $request->headers->get('content-type'); + if ($content_type === NULL) { + throw new BadRequestHttpException("GraphQL requests must specify a valid content type header."); + } + + // application/graphql is a non-standard header that's supported by our + // server implementation and triggers CORS. + if ($content_type === "application/graphql") { + return; + } + + /** @phpstan-ignore-next-line */ + $content_format = method_exists($request, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); + if ($content_format === NULL) { + // Symfony before 5.4 does not detect "multipart/form-data", check for it + // manually. + if (stripos($content_type, 'multipart/form-data') === 0) { + $content_format = 'form'; + } + else { + throw new BadRequestHttpException("The content type '$content_type' is not supported."); + } + } + + // JSON requests provide a non-standard header that trigger CORS. + if ($content_format === "json") { + return; + } + + // The form content types are considered simple requests and don't trigger + // CORS pre-flight checks, so these require a separate header to prevent + // CSRF. We need to support "form" for file uploads. + if ($content_format === "form") { + // If the client set a custom header then we can be sure CORS was + // respected. + $custom_headers = ['Apollo-Require-Preflight', 'X-Apollo-Operation-Name', 'x-graphql-yoga-csrf']; + foreach ($custom_headers as $custom_header) { + if ($request->headers->has($custom_header)) { + return; + } + } + // 1. Allow requests that have set no Origin header at all, for example + // server-to-server requests. + // 2. Allow requests where the Origin matches the site's domain name. + $origin = $request->headers->get('Origin'); + if ($origin === NULL || $request->getSchemeAndHttpHost() === $origin) { + return; + } + // Allow other origins as configured in the CORS policy. + if (!empty($this->corsOptions['enabled'])) { + $cors_service = new CorsService($this->corsOptions); + // Drupal 9 compatibility, method name has changed in Drupal 10. + /** @phpstan-ignore-next-line */ + if ($cors_service->isActualRequestAllowed($request)) { + return; + } + } + throw new BadRequestHttpException("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings."); + } + + throw new BadRequestHttpException("The content type '$content_type' is not supported."); + } + /** * Extracts the query parameters from a request. * diff --git a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php index 961f5559a..6a8ad512f 100644 --- a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php @@ -72,7 +72,7 @@ public function testAutomaticPersistedQueries(): void { // Post query to endpoint with a not matching hash. $content = json_encode(['query' => $query] + $parameters); - $request = Request::create($endpoint, 'POST', [], [], [], [], $content); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content); $result = $this->container->get('http_kernel')->handle($request); $this->assertSame(200, $result->getStatusCode()); $this->assertSame([ @@ -88,7 +88,7 @@ public function testAutomaticPersistedQueries(): void { $parameters['extensions']['persistedQuery']['sha256Hash'] = hash('sha256', $query); $content = json_encode(['query' => $query] + $parameters); - $request = Request::create($endpoint, 'POST', [], [], [], [], $content); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content); $result = $this->container->get('http_kernel')->handle($request); $this->assertSame(200, $result->getStatusCode()); $this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE)); diff --git a/tests/src/Kernel/Framework/CsrfTest.php b/tests/src/Kernel/Framework/CsrfTest.php new file mode 100644 index 000000000..76d12ee07 --- /dev/null +++ b/tests/src/Kernel/Framework/CsrfTest.php @@ -0,0 +1,181 @@ +setUpSchema($schema); + $this->mockResolver('Mutation', 'write', + function () { + $this->mutationTriggered = TRUE; + return TRUE; + } + ); + } + + /** + * Tests that a simple request from an evil origin is not executed. + * + * @dataProvider provideSimpleContentTypes + */ + public function testEvilOrigin(string $content_type): void { + $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [], [ + 'CONTENT_TYPE' => $content_type, + 'HTTP_ORIGIN' => 'https://evil.example.com', + ], '{ "query": "mutation { write }" }'); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertFalse($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(400, $response->getStatusCode()); + } + + /** + * Data provider for testContentTypeCsrf(). + */ + public function provideSimpleContentTypes(): array { + // Three content types that can be sent with simple no-cors POST requests. + return [ + ['text/plain'], + ['application/x-www-form-urlencoded'], + ['multipart/form-data'], + ]; + } + + /** + * Tests that a simple multipart form data no-cors request is not executed. + */ + public function testMultipartFormDataCsrf(): void { + $request = Request::create('https://example.com/graphql/test', 'POST', + [ + 'operations' => '[{ "query": "mutation { write }" }]', + ], + [], + [], + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://evil.example.com', + ] + ); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertFalse($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(400, $response->getStatusCode()); + $result = json_decode($response->getContent()); + $this->assertSame("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings.", $result->message); + } + + /** + * Test that the JSON content types always work, cannot be forged with CSRF. + * + * @dataProvider provideAllowedJsonHeaders + */ + public function testAllowedJsonRequests(array $headers): void { + $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [], + $headers, '{ "query": "mutation { write }" }'); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertTrue($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Data provider for testAllowedJsonRequests(). + */ + public function provideAllowedJsonHeaders(): array { + return [ + [['CONTENT_TYPE' => 'application/json']], + [['CONTENT_TYPE' => 'application/graphql']], + ]; + } + + /** + * Test that a form request with the correct headers against CSRF are allowed. + * + * @dataProvider provideAllowedFormRequests + */ + public function testAllowedFormRequests(array $headers, array $allowedDomains = []): void { + $request = Request::create('https://example.com/graphql/test', 'POST', + [ + 'operations' => '[{ "query": "mutation { write }" }]', + ], [], [], $headers); + + if (!empty($allowedDomains)) { + // Replace the QueryRouteEnhancer to inject CORS config we want to test. + $this->container->set('graphql.route_enhancer.query', new QueryRouteEnhancer([ + 'enabled' => TRUE, + 'allowedOrigins' => $allowedDomains, + ])); + } + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertTrue($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Data provider for testAllowedFormRequests(). + */ + public function provideAllowedFormRequests(): array { + return [ + // Omitting the Origin and Apollo-Require-Preflight is allowed. + [['CONTENT_TYPE' => 'multipart/form-data']], + // The custom Apollo-Require-Preflight header overrules any evil Origin + // header. + [[ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_APOLLO_REQUIRE_PREFLIGHT' => 'test', + 'HTTP_ORIGIN' => 'https://evil.example.com', + ]], + // The Origin header alone with the correct domain is allowed. + [[ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://example.com', + ]], + // The Origin header with an allowed domain. + [[ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], ['https://allowed.example.com']], + // The Origin header with any allowed domain. + [[ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], ['*']], + ]; + } + +} diff --git a/tests/src/Traits/HttpRequestTrait.php b/tests/src/Traits/HttpRequestTrait.php index 69fd4e1ca..c1307c418 100644 --- a/tests/src/Traits/HttpRequestTrait.php +++ b/tests/src/Traits/HttpRequestTrait.php @@ -56,7 +56,7 @@ protected function query($query, $server = NULL, array $variables = [], array $e $request = Request::create($endpoint, $method, $data); } else { - $request = Request::create($endpoint, $method, [], [], [], [], json_encode($data)); + $request = Request::create($endpoint, $method, [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data)); } return $this->container->get('http_kernel')->handle($request); @@ -81,7 +81,7 @@ protected function batchedQueries(array $queries, ServerInterface $server = NULL $queries = json_encode($queries); $endpoint = $this->server->get('endpoint'); - $request = Request::create($endpoint, 'POST', [], [], [], [], $queries); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $queries); return $this->container->get('http_kernel')->handle($request); } From f12daab4fb2797b10bf3c472416b8af4f6d8543a Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Wed, 8 Nov 2023 14:09:53 +0100 Subject: [PATCH 10/18] style(routing): Fix PHPCS errors --- src/Routing/QueryRouteEnhancer.php | 10 ++++-- tests/src/Kernel/Framework/CsrfTest.php | 44 +++++++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/Routing/QueryRouteEnhancer.php b/src/Routing/QueryRouteEnhancer.php index 0d8fec5dd..ca2daa5df 100644 --- a/src/Routing/QueryRouteEnhancer.php +++ b/src/Routing/QueryRouteEnhancer.php @@ -92,7 +92,7 @@ protected function assertValidPostRequestHeaders(Request $request) : void { return; } - /** @phpstan-ignore-next-line */ + // @phpstan-ignore-next-line $content_format = method_exists($request, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); if ($content_format === NULL) { // Symfony before 5.4 does not detect "multipart/form-data", check for it @@ -116,7 +116,11 @@ protected function assertValidPostRequestHeaders(Request $request) : void { if ($content_format === "form") { // If the client set a custom header then we can be sure CORS was // respected. - $custom_headers = ['Apollo-Require-Preflight', 'X-Apollo-Operation-Name', 'x-graphql-yoga-csrf']; + $custom_headers = [ + 'Apollo-Require-Preflight', + 'X-Apollo-Operation-Name', + 'x-graphql-yoga-csrf', + ]; foreach ($custom_headers as $custom_header) { if ($request->headers->has($custom_header)) { return; @@ -133,7 +137,7 @@ protected function assertValidPostRequestHeaders(Request $request) : void { if (!empty($this->corsOptions['enabled'])) { $cors_service = new CorsService($this->corsOptions); // Drupal 9 compatibility, method name has changed in Drupal 10. - /** @phpstan-ignore-next-line */ + // @phpstan-ignore-next-line if ($cors_service->isActualRequestAllowed($request)) { return; } diff --git a/tests/src/Kernel/Framework/CsrfTest.php b/tests/src/Kernel/Framework/CsrfTest.php index 76d12ee07..34d451b30 100644 --- a/tests/src/Kernel/Framework/CsrfTest.php +++ b/tests/src/Kernel/Framework/CsrfTest.php @@ -155,26 +155,36 @@ public function provideAllowedFormRequests(): array { [['CONTENT_TYPE' => 'multipart/form-data']], // The custom Apollo-Require-Preflight header overrules any evil Origin // header. - [[ - 'CONTENT_TYPE' => 'multipart/form-data', - 'HTTP_APOLLO_REQUIRE_PREFLIGHT' => 'test', - 'HTTP_ORIGIN' => 'https://evil.example.com', - ]], + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_APOLLO_REQUIRE_PREFLIGHT' => 'test', + 'HTTP_ORIGIN' => 'https://evil.example.com', + ], + ], // The Origin header alone with the correct domain is allowed. - [[ - 'CONTENT_TYPE' => 'multipart/form-data', - 'HTTP_ORIGIN' => 'https://example.com', - ]], + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://example.com', + ], + ], // The Origin header with an allowed domain. - [[ - 'CONTENT_TYPE' => 'multipart/form-data', - 'HTTP_ORIGIN' => 'https://allowed.example.com', - ], ['https://allowed.example.com']], + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], + ['https://allowed.example.com'], + ], // The Origin header with any allowed domain. - [[ - 'CONTENT_TYPE' => 'multipart/form-data', - 'HTTP_ORIGIN' => 'https://allowed.example.com', - ], ['*']], + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], + ['*'], + ], ]; } From 0666a1f9b2f688cd85f28f92abf01bfcb6a3ead7 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Wed, 8 Nov 2023 14:21:06 +0100 Subject: [PATCH 11/18] fix(dataproducer): Fix entity label handling (by mxr576) --- .../DataProducer/Entity/EntityLabel.php | 21 +++++++++++++--- tests/src/Kernel/DataProducer/EntityTest.php | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php index 7e9ec9272..7b6726359 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php @@ -3,6 +3,8 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\DataProducerPluginCachingInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; @@ -19,7 +21,12 @@ * consumes = { * "entity" = @ContextDefinition("entity", * label = @Translation("Entity") - * ) + * ), + * "access_user" = @ContextDefinition("entity:user", + * label = @Translation("User"), + * required = FALSE, + * default_value = NULL + * ), * } * ) */ @@ -29,11 +36,19 @@ class EntityLabel extends DataProducerPluginBase implements DataProducerPluginCa * Resolver. * * @param \Drupal\Core\Entity\EntityInterface $entity + * @param \Drupal\Core\Session\AccountInterface|null $accessUser + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return string|null */ - public function resolve(EntityInterface $entity) { - return $entity->label(); + public function resolve(EntityInterface $entity, ?AccountInterface $accessUser, FieldContext $context) { + /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ + $accessResult = $entity->access('view label', $accessUser, TRUE); + $context->addCacheableDependency($accessResult); + if ($accessResult->isAllowed()) { + return $entity->label(); + } + return NULL; } } diff --git a/tests/src/Kernel/DataProducer/EntityTest.php b/tests/src/Kernel/DataProducer/EntityTest.php index b214f5047..7ea5a369c 100644 --- a/tests/src/Kernel/DataProducer/EntityTest.php +++ b/tests/src/Kernel/DataProducer/EntityTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; @@ -199,9 +200,32 @@ public function testResolveLabel(): void { ->method('label') ->willReturn('Dummy label'); + $this->entity->expects($this->exactly(2)) + ->method('access') + ->willReturnCallback(static function (): AccessResult { + static $counter = 0; + switch ($counter) { + case 0: + $counter++; + return AccessResult::allowed(); + + case 1: + $counter++; + return AccessResult::forbidden(); + + default: + throw new \LogicException('The access() method should not have been called more than twice.'); + } + }) + ->with('view label', NULL, TRUE); + $this->assertEquals('Dummy label', $this->executeDataProducer('entity_label', [ 'entity' => $this->entity, ])); + + $this->assertNull($this->executeDataProducer('entity_label', [ + 'entity' => $this->entity, + ])); } /** From 0fdee57a217d7085fce27ab8aa785bf4f97af2cb Mon Sep 17 00:00:00 2001 From: Jan Hug Date: Fri, 10 Nov 2023 09:00:29 +0100 Subject: [PATCH 12/18] fix(entity_access): Add access result as cacheable dependency (#1372) --- src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php | 9 ++++++--- tests/src/Kernel/DataProducer/EntityTest.php | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php index 7fa645259..9963f4e34 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php @@ -4,6 +4,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; /** @@ -40,10 +41,12 @@ class EntityAccess extends DataProducerPluginBase { * @param string $operation * @param \Drupal\Core\Session\AccountInterface $user * - * @return bool|\Drupal\Core\Access\AccessResultInterface + * @return bool */ - public function resolve(EntityInterface $entity, $operation = NULL, AccountInterface $user = NULL) { - return $entity->access($operation ?? 'view', $user); + public function resolve(EntityInterface $entity, ?string $operation, ?AccountInterface $user, FieldContext $context) { + $result = $entity->access($operation ?? 'view', $user, TRUE); + $context->addCacheableDependency($result); + return $result->isAllowed(); } } diff --git a/tests/src/Kernel/DataProducer/EntityTest.php b/tests/src/Kernel/DataProducer/EntityTest.php index 7ea5a369c..69496e60b 100644 --- a/tests/src/Kernel/DataProducer/EntityTest.php +++ b/tests/src/Kernel/DataProducer/EntityTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultForbidden; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; @@ -298,7 +299,7 @@ public function testResolvePublished(): void { public function testResolveAccess(): void { $this->entity->expects($this->any()) ->method('access') - ->willReturn(FALSE); + ->willReturn(new AccessResultForbidden()); $this->assertFalse($this->executeDataProducer('entity_access', [ 'entity' => $this->entity, From 1017cbc221bf8013a12a41b52324cadbdf20d250 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Fri, 10 Nov 2023 09:06:31 +0100 Subject: [PATCH 13/18] docs(entity_access): Add param comments to entity_access data producer (#1374) --- src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php index 9963f4e34..a641101bf 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php @@ -38,10 +38,16 @@ class EntityAccess extends DataProducerPluginBase { * Resolver. * * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check access for. * @param string $operation + * The access operation, for example "view". * @param \Drupal\Core\Session\AccountInterface $user + * The user account access should be checked for. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The context to add caching information to. * * @return bool + * TRUE when access to the entity is allowed, FALSE otherwise. */ public function resolve(EntityInterface $entity, ?string $operation, ?AccountInterface $user, FieldContext $context) { $result = $entity->access($operation ?? 'view', $user, TRUE); From 840f2c80ee08d9c4872360310652d59cb9190978 Mon Sep 17 00:00:00 2001 From: Jan Hug Date: Fri, 10 Nov 2023 10:06:58 +0100 Subject: [PATCH 14/18] fix(caching): Take operation into account for cache prefix (#1365) (#1366) --- src/GraphQL/Execution/Executor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/GraphQL/Execution/Executor.php b/src/GraphQL/Execution/Executor.php index 0d0e92edd..b8ed3a6cb 100644 --- a/src/GraphQL/Execution/Executor.php +++ b/src/GraphQL/Execution/Executor.php @@ -356,6 +356,7 @@ protected function cachePrefix() { 'query' => DocumentSerializer::serializeDocument($this->document), 'variables' => $variables, 'extensions' => $extensions, + 'operation' => $this->operation, ])); return $hash; From 926833235883efa1755789db3b030713c6428d52 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Fri, 10 Nov 2023 10:31:53 +0100 Subject: [PATCH 15/18] test(caching): Add test case for operation name in cache prefix (#1375) --- .../Kernel/Framework/PersistedQueriesTest.php | 2 +- .../src/Kernel/Framework/ResultCacheTest.php | 31 +++++++++++++++++++ tests/src/Kernel/Framework/ResultTest.php | 2 +- tests/src/Traits/HttpRequestTrait.php | 21 +++++++++---- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/tests/src/Kernel/Framework/PersistedQueriesTest.php b/tests/src/Kernel/Framework/PersistedQueriesTest.php index 913d20258..e28e0b5d9 100644 --- a/tests/src/Kernel/Framework/PersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/PersistedQueriesTest.php @@ -66,7 +66,7 @@ public function testPersistedQueries(array $instanceIds, string $queryId, array } $this->server->save(); - $result = $this->query($queryId, NULL, [], NULL, TRUE); + $result = $this->query($queryId, NULL, [], [], TRUE); $this->assertSame(200, $result->getStatusCode()); $this->assertSame($expected, json_decode($result->getContent(), TRUE)); diff --git a/tests/src/Kernel/Framework/ResultCacheTest.php b/tests/src/Kernel/Framework/ResultCacheTest.php index e44e18f75..68ec90679 100644 --- a/tests/src/Kernel/Framework/ResultCacheTest.php +++ b/tests/src/Kernel/Framework/ResultCacheTest.php @@ -11,6 +11,7 @@ use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use GraphQL\Deferred; use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; /** * Test query result caching. @@ -385,4 +386,34 @@ function ($a, $b, $c, $d, FieldContext $field) use ($renderer) { $this->assertEquals(200, $result->getStatusCode()); } + /** + * Ensure that a different operation name does not get a cached result. + */ + public function testOperationNameCaching(): void { + $dummy = $this->getMockBuilder(Server::class) + ->disableOriginalConstructor() + ->onlyMethods(['id']) + ->getMock(); + + // The dataproducer should be called twice because 2 differently named + // queries are not cached. + $dummy->expects($this->exactly(2)) + ->method('id') + ->willReturn('test'); + + // Use the same resolver for both fields. + foreach (['root', 'leakA'] as $field_name) { + $this->mockResolver('Query', $field_name, + function () use ($dummy) { + return $dummy->id(); + } + ); + } + + // First call is uncached. + $this->query('query one { root } query two { leakA }', NULL, [], [], FALSE, Request::METHOD_GET, 'one'); + // Second call is uncached. + $this->query('query one { root } query two { leakA }', NULL, [], [], FALSE, Request::METHOD_GET, 'two'); + } + } diff --git a/tests/src/Kernel/Framework/ResultTest.php b/tests/src/Kernel/Framework/ResultTest.php index f1b7acdb7..ff38ae0b5 100644 --- a/tests/src/Kernel/Framework/ResultTest.php +++ b/tests/src/Kernel/Framework/ResultTest.php @@ -58,7 +58,7 @@ public function testQuery(): void { * @coversClass \Drupal\graphql\Cache\RequestPolicy\DenyPost */ public function testPostQuery(): void { - $result = $this->query('query { root }', NULL, [], NULL, FALSE, Request::METHOD_POST); + $result = $this->query('query { root }', NULL, [], [], FALSE, Request::METHOD_POST); $this->assertSame(200, $result->getStatusCode()); $this->assertSame([ 'data' => [ diff --git a/tests/src/Traits/HttpRequestTrait.php b/tests/src/Traits/HttpRequestTrait.php index c1307c418..b7b4174bb 100644 --- a/tests/src/Traits/HttpRequestTrait.php +++ b/tests/src/Traits/HttpRequestTrait.php @@ -27,22 +27,28 @@ trait HttpRequestTrait { * The server instance. * @param array $variables * Query variables. - * @param array|null $extensions + * @param array $extensions * The query extensions. * @param bool $persisted * Flag if the query is actually the identifier of a persisted query. * @param string $method * Method, GET or POST. + * @param string $operationName + * Optional operation name if $query contains multiple operations. * * @return \Symfony\Component\HttpFoundation\Response * The http response object. */ - protected function query($query, $server = NULL, array $variables = [], array $extensions = NULL, $persisted = FALSE, string $method = Request::METHOD_GET) { + protected function query( + string $query, + ?Server $server = NULL, + array $variables = [], + array $extensions = [], + bool $persisted = FALSE, + string $method = Request::METHOD_GET, + string $operationName = '' + ) { $server = $server ?: $this->server; - if (!($server instanceof Server)) { - throw new \LogicException('Invalid server.'); - } - $endpoint = $this->server->get('endpoint'); $extensions = !empty($extensions) ? ['extensions' => $extensions] : []; // If the persisted flag is true, then instead of sending the full query to @@ -52,6 +58,9 @@ protected function query($query, $server = NULL, array $variables = [], array $e $query_key => $query, 'variables' => $variables, ] + $extensions; + if ($operationName) { + $data['operationName'] = $operationName; + } if ($method === Request::METHOD_GET) { $request = Request::create($endpoint, $method, $data); } From 9e998625ee837e6205f4facece64c08ffb2f0892 Mon Sep 17 00:00:00 2001 From: Alexander Tkachev Date: Sat, 11 Nov 2023 12:46:21 +0400 Subject: [PATCH 16/18] fix(schema): Allow big schemas to be serialized and cached (#1364) --- src/Plugin/GraphQL/Schema/AlterableComposableSchema.php | 4 ++-- src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php index 3088322cc..31dda11f2 100644 --- a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php @@ -130,7 +130,7 @@ protected function getSchemaDocument(array $extensions = []) { $event, AlterSchemaDataEvent::EVENT_NAME ); - $ast = Parser::parse(implode("\n\n", $event->getSchemaData())); + $ast = Parser::parse(implode("\n\n", $event->getSchemaData()), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } @@ -172,7 +172,7 @@ protected function getExtensionDocument(array $extensions = []) { $event, AlterSchemaExtensionDataEvent::EVENT_NAME ); - $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $event->getSchemaExtensionData())) : NULL; + $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $event->getSchemaExtensionData()), ['noLocation' => TRUE]) : NULL; if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } diff --git a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php index 47d1e7f2c..ffd25b128 100644 --- a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php +++ b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php @@ -174,7 +174,7 @@ protected function getSchemaDocument(array $extensions = []) { }); $schema = array_merge([$this->getSchemaDefinition()], $extensions); - $ast = Parser::parse(implode("\n\n", $schema)); + $ast = Parser::parse(implode("\n\n", $schema), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } @@ -205,7 +205,7 @@ protected function getExtensionDocument(array $extensions = []) { return !empty($definition); }); - $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $extensions)) : NULL; + $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $extensions), ['noLocation' => TRUE]) : NULL; if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } From 5d7ceca4cafe0b746831469fb04dc1816a877e48 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Sat, 11 Nov 2023 09:55:18 +0100 Subject: [PATCH 17/18] docs(schema): Add comment about the noLocation option (#1376) --- src/Plugin/GraphQL/Schema/AlterableComposableSchema.php | 3 +++ src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php index 31dda11f2..0f50a9f12 100644 --- a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php @@ -130,6 +130,9 @@ protected function getSchemaDocument(array $extensions = []) { $event, AlterSchemaDataEvent::EVENT_NAME ); + // For caching and parsing big schemas we need to disable the creation of + // location nodes in the AST object to prevent serialization and memory + // errors. See https://github.com/webonyx/graphql-php/issues/1164 $ast = Parser::parse(implode("\n\n", $event->getSchemaData()), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); diff --git a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php index ffd25b128..cae50d5d0 100644 --- a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php +++ b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php @@ -174,6 +174,9 @@ protected function getSchemaDocument(array $extensions = []) { }); $schema = array_merge([$this->getSchemaDefinition()], $extensions); + // For caching and parsing big schemas we need to disable the creation of + // location nodes in the AST object to prevent serialization and memory + // errors. See https://github.com/webonyx/graphql-php/issues/1164 $ast = Parser::parse(implode("\n\n", $schema), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); From 6798e26ad6cc785322ad0618b2592d22d493da98 Mon Sep 17 00:00:00 2001 From: Klaus Purer Date: Sat, 11 Nov 2023 10:38:28 +0100 Subject: [PATCH 18/18] fix(ServerForm): Fix AJAX error when converting debug flags (#1377) --- src/Form/ServerForm.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Form/ServerForm.php b/src/Form/ServerForm.php index c1b747e37..eb955f315 100644 --- a/src/Form/ServerForm.php +++ b/src/Form/ServerForm.php @@ -7,6 +7,7 @@ use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\SubformState; @@ -243,6 +244,19 @@ public function form(array $form, FormStateInterface $formState): array { return $form; } + /** + * {@inheritdoc} + */ + protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state): void { + // Translate the debug flag from individual checkboxes to the enum value + // that the GraphQL library expects. + $debug_flag = $form_state->getValue('debug_flag'); + if (is_array($debug_flag)) { + $form_state->setValue('debug_flag', array_sum($debug_flag)); + } + parent::copyFormValuesToEntity($entity, $form, $form_state); + } + /** * {@inheritdoc} * @@ -274,9 +288,6 @@ public function validateForm(array &$form, FormStateInterface $formState): void * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $formState): void { - // Translate the debug flag from individual checkboxes to the enum value - // that the GraphQL library expects. - $formState->setValue('debug_flag', array_sum($formState->getValue('debug_flag'))); parent::submitForm($form, $formState); $schema = $formState->getValue('schema');