diff --git a/docs/developer-guide/headless.md b/docs/developer-guide/headless.md index f7083210d..73f589b6f 100644 --- a/docs/developer-guide/headless.md +++ b/docs/developer-guide/headless.md @@ -164,6 +164,33 @@ Then we add the $path variable with a json string like this: This variable can be added in the GraphQL explorer in the corresponding input field. All following examples will assume a variable definition like this. +#### Requesting revisions + +Given a user has the permission to access revisions of content, the $path can contain revision routes as well. +Revision routes are always internal drupal routes. +If /example-page is Node 6 in Drupal, the following variables are valid: + +Get the current revision, which is the default query to the currently published page. +```json +{ + "path": "/example-page" +} +``` + +Get a specific revision, can be an old revision, or a not yet published draft +```json +{ + "path": "/node/6/revision/11/view" +} +``` + +Get the latest revision, which might be an unpublished draft. The latest revision might not be available for a given node. +```json +{ + "path": "/node/6/latest" +} +``` + #### Paragraphs example Articles and taxonomy terms contain paragraph fields in Thunder, the following example shows how to request paragraphs' diff --git a/modules/thunder_gqls/src/Plugin/GraphQL/DataProducer/ThunderRouteEntity.php b/modules/thunder_gqls/src/Plugin/GraphQL/DataProducer/ThunderRouteEntity.php new file mode 100644 index 000000000..14063abca --- /dev/null +++ b/modules/thunder_gqls/src/Plugin/GraphQL/DataProducer/ThunderRouteEntity.php @@ -0,0 +1,164 @@ +get('entity_type.manager'), + $container->get('graphql.buffer.entity'), + $container->get('graphql.buffer.entity_revision') + ); + } + + /** + * RouteEntity constructor. + * + * @param array $configuration + * The plugin configuration array. + * @param string $pluginId + * The plugin id. + * @param mixed $pluginDefinition + * The plugin definition array. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The language manager service. + * @param \Drupal\graphql\GraphQL\Buffers\EntityBuffer $entityBuffer + * The entity buffer service. + * @param \Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer $entityRevisionBuffer + * The entity revision buffer service. + * + * @codeCoverageIgnore + */ + public function __construct( + array $configuration, + $pluginId, + $pluginDefinition, + EntityTypeManagerInterface $entityTypeManager, + EntityBuffer $entityBuffer, + EntityRevisionBuffer $entityRevisionBuffer + ) { + parent::__construct($configuration, $pluginId, $pluginDefinition); + $this->entityTypeManager = $entityTypeManager; + $this->entityBuffer = $entityBuffer; + $this->entityRevisionBuffer = $entityRevisionBuffer; + } + + /** + * Resolver. + * + * @param \Drupal\Core\Url|mixed $url + * The URL to get the route entity from. + * @param string|null $language + * The language code to get a translation of the entity. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The GraphQL field context. + */ + public function resolve($url, ?string $language, FieldContext $context): ?Deferred { + if (!$url instanceof Url) { + return NULL; + } + + [, $type, $subType] = explode('.', $url->getRouteName()); + $parameters = $url->getRouteParameters(); + $storage = $this->entityTypeManager->getStorage($type); + + if ($subType === 'latest_version' && $storage instanceof RevisionableStorageInterface) { + $id = $storage->getLatestRevisionId($parameters[$type]); + $resolver = $this->entityRevisionBuffer->add($type, $id); + } + elseif ($subType === 'revision') { + $resolver = $this->entityRevisionBuffer->add($type, $parameters[$type . '_revision']); + } + else { + $resolver = $this->entityBuffer->add($type, $parameters[$type]); + } + + return new Deferred(function () use ($type, $resolver, $context, $language) { + if (!$entity = $resolver()) { + // If there is no entity with this id, add the list cache tags so that + // the cache entry is purged whenever a new entity of this type is + // saved. + $type = $this->entityTypeManager->getDefinition($type); + /** @var \Drupal\Core\Entity\EntityTypeInterface $type */ + $tags = $type->getListCacheTags(); + $context->addCacheTags($tags)->addCacheTags(['4xx-response']); + return NULL; + } + + // Get the correct translation. + if (isset($language) && $language != $entity->language()->getId() && $entity instanceof TranslatableInterface) { + $entity = $entity->getTranslation($language); + $entity->addCacheContexts(["static:language:{$language}"]); + } + + $access = $entity->access('view', NULL, TRUE); + $context->addCacheableDependency($access); + if ($access->isAllowed()) { + return $entity; + } + return NULL; + }); + } + +} diff --git a/modules/thunder_gqls/src/Traits/ResolverHelperTrait.php b/modules/thunder_gqls/src/Traits/ResolverHelperTrait.php index a3f529d19..26712d2ab 100644 --- a/modules/thunder_gqls/src/Traits/ResolverHelperTrait.php +++ b/modules/thunder_gqls/src/Traits/ResolverHelperTrait.php @@ -111,7 +111,7 @@ public function fromRoute(ResolverInterface $path) { return $this->builder->compose( $this->builder->produce('route_load') ->map('path', $path), - $this->builder->produce('route_entity') + $this->builder->produce('thunder_route_entity') ->map('url', $this->builder->fromParent()) ->map('language', $this->builder->produce('thunder_language') ->map('path', $path) diff --git a/modules/thunder_gqls/tests/src/Functional/SchemaTest.php b/modules/thunder_gqls/tests/src/Functional/SchemaTest.php index 8f17f8c55..10bbca75f 100644 --- a/modules/thunder_gqls/tests/src/Functional/SchemaTest.php +++ b/modules/thunder_gqls/tests/src/Functional/SchemaTest.php @@ -39,7 +39,7 @@ public function testSchema(): void { } /** - * Tests the article schema. + * Tests unpublished access. * * @throws \GuzzleHttp\Exception\GuzzleException */ @@ -156,4 +156,31 @@ public function testValidSchema(): void { $this->assertEmpty($validator->getMissingResolvers($server), "The schema 'thunder_graphql' contains types without a resolver."); } + /** + * Test the latest revision query. + */ + public function testLatestRevision(): void { + $node = Node::create([ + 'title' => 'Test node', + 'field_seo_title' => 'SEO title', + 'type' => 'article', + 'status' => Node::NOT_PUBLISHED, + ]); + $node->save(); + + $query = << $node->toUrl()->toString()]; + $response = $this->query($query, Json::encode($variables)); + $this->assertEquals(200, $response->getStatusCode(), 'Response not 200'); + $this->assertEquals(['seoTitle' => 'SEO title'], $this->jsonDecode($response->getBody())['data']['page']); + } + } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b0a637324..36ec2d1e4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -55,6 +55,11 @@ parameters: count: 1 path: modules/thunder_gqls/src/Plugin/GraphQL/DataProducer/ThunderRedirect.php + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: modules/thunder_gqls/src/Plugin/GraphQL/DataProducer/ThunderRouteEntity.php + - message: "#^Access to an undefined property Drupal\\\\Core\\\\Entity\\\\ContentEntityInterface\\:\\:\\$field_teaser_media\\.$#" count: 1