Skip to content

Commit 81f21a1

Browse files
authored
feat(persisted_queries): add Apollo's Automated Persisted Query support (#1189)
1 parent 31c4753 commit 81f21a1

5 files changed

Lines changed: 233 additions & 1 deletion

File tree

graphql.services.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ services:
3939
tags:
4040
- { name: cache.context }
4141

42+
# Cache bin for the persisted queries.
43+
cache.graphql.apq:
44+
class: Drupal\Core\Cache\CacheBackendInterface
45+
tags:
46+
- { name: cache.bin }
47+
factory: cache_factory:get
48+
arguments: [graphql_apq]
49+
4250
# Cache bin for the parsed sdl ast.
4351
cache.graphql.ast:
4452
class: Drupal\Core\Cache\CacheBackendInterface
@@ -116,6 +124,13 @@ services:
116124
tags:
117125
- { name: event_subscriber }
118126

127+
# Cache the queries to be persistent.
128+
graphql.apq_subscriber:
129+
class: Drupal\graphql\EventSubscriber\ApqSubscriber
130+
arguments: ['@cache.graphql.apq']
131+
tags:
132+
- { name: event_subscriber }
133+
119134
# Plugin manager for schemas
120135
plugin.manager.graphql.schema:
121136
class: Drupal\graphql\Plugin\SchemaPluginManager

src/Entity/ServerInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public function removeAllPersistedQueryInstances();
6060
/**
6161
* Returns the current persisted queries set.
6262
*
63-
* @return \Drupal\graphql\Plugin\PersistedQueryPluginInterface[]
63+
* @return \Drupal\graphql\Plugin\PersistedQueryPluginInterface[]|null
6464
*/
6565
public function getPersistedQueryInstances();
6666

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace Drupal\graphql\EventSubscriber;
4+
5+
use Drupal\Core\Cache\CacheBackendInterface;
6+
use Drupal\graphql\Event\OperationEvent;
7+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
8+
use GraphQL\Error\Error;
9+
10+
/**
11+
* Save persisted queries to cache.
12+
*/
13+
class ApqSubscriber implements EventSubscriberInterface {
14+
15+
/**
16+
* The cache to store persisted queries.
17+
*
18+
* @var \Drupal\Core\Cache\CacheBackendInterface
19+
*/
20+
protected $cache;
21+
22+
/**
23+
* Constructs a ApqSubscriber object.
24+
*
25+
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
26+
* The cache to store persisted queries.
27+
*/
28+
public function __construct(CacheBackendInterface $cache) {
29+
$this->cache = $cache;
30+
}
31+
32+
/**
33+
* Handle operation start events.
34+
*
35+
* @param \Drupal\graphql\Event\OperationEvent $event
36+
* The kernel event object.
37+
*
38+
* @throws \GraphQL\Error\Error
39+
*/
40+
public function onBeforeOperation(OperationEvent $event): void {
41+
if (!array_key_exists('automatic_persisted_query', $event->getContext()->getServer()->getPersistedQueryInstances() ?? [])) {
42+
return;
43+
}
44+
$query = $event->getContext()->getOperation()->query;
45+
$queryHash = $event->getContext()->getOperation()->extensions['persistedQuery']['sha256Hash'] ?? '';
46+
47+
if (is_string($query) && is_string($queryHash) && $queryHash !== '') {
48+
$computedQueryHash = hash('sha256', $query);
49+
if ($queryHash !== $computedQueryHash) {
50+
throw new Error('Provided sha does not match query');
51+
}
52+
$this->cache->set($queryHash, $query);
53+
}
54+
}
55+
56+
/**
57+
* {@inheritdoc}
58+
*/
59+
public static function getSubscribedEvents() {
60+
return [
61+
OperationEvent::GRAPHQL_OPERATION_BEFORE => 'onBeforeOperation',
62+
];
63+
}
64+
65+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Drupal\graphql\Plugin\GraphQL\PersistedQuery;
4+
5+
use Drupal\Core\Cache\CacheBackendInterface;
6+
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
7+
use Drupal\graphql\PersistedQuery\PersistedQueryPluginBase;
8+
use GraphQL\Server\OperationParams;
9+
use GraphQL\Server\RequestError;
10+
use Symfony\Component\DependencyInjection\ContainerInterface;
11+
12+
/**
13+
* Load persisted queries from the cache.
14+
*
15+
* @PersistedQuery(
16+
* id = "automatic_persisted_query",
17+
* label = "Automatic Persisted Query",
18+
* description = "Load persisted queries from the cache."
19+
* )
20+
*/
21+
class AutomaticPersistedQuery extends PersistedQueryPluginBase implements ContainerFactoryPluginInterface {
22+
23+
/**
24+
* The cache to store persisted queries.
25+
*
26+
* @var \Drupal\Core\Cache\CacheBackendInterface
27+
*/
28+
protected $cache;
29+
30+
/**
31+
* {@inheritdoc}
32+
*/
33+
public function __construct(array $configuration, $plugin_id, $plugin_definition, CacheBackendInterface $cache) {
34+
parent::__construct($configuration, $plugin_id, $plugin_definition);
35+
36+
$this->cache = $cache;
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
43+
return new static($configuration, $plugin_id, $plugin_definition, $container->get('cache.graphql.apq'));
44+
}
45+
46+
/**
47+
* {@inheritdoc}
48+
*/
49+
public function getQuery($id, OperationParams $operation) {
50+
if ($query = $this->cache->get($id)) {
51+
return $query->data;
52+
}
53+
throw new RequestError('PersistedQueryNotFound');
54+
}
55+
56+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace Drupal\Tests\graphql\Kernel\Framework;
4+
5+
use Drupal\Tests\graphql\Kernel\GraphQLTestBase;
6+
use Symfony\Component\HttpFoundation\Request;
7+
8+
/**
9+
* Tests the automatic persisted query plugin.
10+
*
11+
* @group graphql
12+
*/
13+
class AutomaticPersistedQueriesTest extends GraphQLTestBase {
14+
15+
/**
16+
* {@inheritdoc}
17+
*/
18+
protected function setUp(): void {
19+
parent::setUp();
20+
$schema = <<<GQL
21+
schema {
22+
query: Query
23+
}
24+
type Query {
25+
field_one: String
26+
}
27+
GQL;
28+
29+
$this->setUpSchema($schema);
30+
$this->mockResolver('Query', 'field_one', 'this is the field one');
31+
32+
/** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */
33+
$manager = $this->container->get('plugin.manager.graphql.persisted_query');
34+
35+
$this->plugin_apq = $manager->createInstance('automatic_persisted_query');
36+
}
37+
38+
/**
39+
* Test the automatic persisted queries plugin.
40+
*/
41+
public function testAutomaticPersistedQueries(): void {
42+
// Before adding the persisted query plugins to the server, we want to make
43+
// sure that there are no existing plugins already there.
44+
$this->server->removeAllPersistedQueryInstances();
45+
$this->server->addPersistedQueryInstance($this->plugin_apq);
46+
$this->server->save();
47+
48+
$endpoint = $this->server->get('endpoint');
49+
50+
$query = 'query { field_one } ';
51+
$parameters['extensions']['persistedQuery']['sha256Hash'] = 'some random hash';
52+
53+
// Check we get PersistedQueryNotFound.
54+
$request = Request::create($endpoint, 'GET', $parameters);
55+
$result = $this->container->get('http_kernel')->handle($request);
56+
$this->assertSame(200, $result->getStatusCode());
57+
$this->assertSame([
58+
'errors' => [
59+
[
60+
'message' => 'PersistedQueryNotFound',
61+
'extensions' => ['category' => 'request'],
62+
],
63+
],
64+
], json_decode($result->getContent(), TRUE));
65+
66+
// Post query to endpoint with a not matching hash.
67+
$content = json_encode(['query' => $query] + $parameters);
68+
$request = Request::create($endpoint, 'POST', [], [], [], [], $content);
69+
$result = $this->container->get('http_kernel')->handle($request);
70+
$this->assertSame(200, $result->getStatusCode());
71+
$this->assertSame([
72+
'errors' => [
73+
[
74+
'message' => 'Provided sha does not match query',
75+
'extensions' => ['category' => 'graphql'],
76+
],
77+
],
78+
], json_decode($result->getContent(), TRUE));
79+
80+
// Post query to endpoint to get the result and cache it.
81+
$parameters['extensions']['persistedQuery']['sha256Hash'] = hash('sha256', $query);
82+
83+
$content = json_encode(['query' => $query] + $parameters);
84+
$request = Request::create($endpoint, 'POST', [], [], [], [], $content);
85+
$result = $this->container->get('http_kernel')->handle($request);
86+
$this->assertSame(200, $result->getStatusCode());
87+
$this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE));
88+
89+
// Execute first request again.
90+
$request = Request::create($endpoint, 'GET', $parameters);
91+
$result = $this->container->get('http_kernel')->handle($request);
92+
$this->assertSame(200, $result->getStatusCode());
93+
$this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE));
94+
}
95+
96+
}

0 commit comments

Comments
 (0)